Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a0570706f | |||
| 9ac8fdface | |||
| 33c92d1352 |
@@ -11,6 +11,7 @@ artifacts/self-repair/
|
||||
artifacts/*.log
|
||||
tmp/*.log
|
||||
.codex_tmp/
|
||||
/build/
|
||||
firmware/t2g/Seat_Controller_demo/Tests/manual_hil/logs/
|
||||
firmware/t2g/Seat_Controller_demo/Tests/manual_hil/seat_function_runtime/
|
||||
tools/zcan/seat_function_runtime/
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
.PHONY: validate test dev dev-lan
|
||||
.PHONY: validate test dev dev-lan can-protocol-generate can-protocol-check can-read-trans can-read-trans-test
|
||||
|
||||
validate:
|
||||
CAN_READ_TRANS_BUILD_DIR ?= build/can_read_trans
|
||||
CAN_READ_TRANS_CANFD_ROOT ?= /home/orin/project511/project-code/canfd
|
||||
|
||||
can-protocol-generate:
|
||||
python tools/generate_can_semantic_protocol.py
|
||||
|
||||
can-protocol-check:
|
||||
python tools/generate_can_semantic_protocol.py --check
|
||||
|
||||
validate: can-protocol-check
|
||||
python tools/validate_repository.py
|
||||
|
||||
test:
|
||||
test: can-protocol-check
|
||||
PYTHONPATH=src:domains/seat/src python -m unittest discover -s domains/seat/tests -p 'test_*.py'
|
||||
PYTHONPATH=src:domains/seat/src python -m unittest discover -s tests -p 'test_*.py'
|
||||
|
||||
@@ -12,3 +21,12 @@ dev:
|
||||
|
||||
dev-lan:
|
||||
PYTHONPATH=src:domains/seat/src python -m cognitive_agent.server --host 0.0.0.0
|
||||
|
||||
can-read-trans: can-protocol-generate
|
||||
cmake -S tools/can_read_trans -B $(CAN_READ_TRANS_BUILD_DIR) \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCANFD_ROOT=$(CAN_READ_TRANS_CANFD_ROOT)
|
||||
cmake --build $(CAN_READ_TRANS_BUILD_DIR) --parallel
|
||||
|
||||
can-read-trans-test: can-read-trans
|
||||
ctest --test-dir $(CAN_READ_TRANS_BUILD_DIR) --output-on-failure
|
||||
|
||||
@@ -116,10 +116,18 @@ config/gesture/default.yaml
|
||||
Linux 上使用 USBCANFD-200U 被动同步 CH1 车辆状态时,使用
|
||||
`zlgcan-linux` 后端。USB 权限、60 MHz/500k/2M 位时序、只读配置和状态接口
|
||||
验证见 [Linux ZLG 车辆状态接入](docs/workflows/linux-zlg-vehicle-state.md)。
|
||||
同一页面也可选择本机 NanoMQ 无线链路,其中 CAN0 对应副驾座椅、CAN1 对应
|
||||
其余车辆状态和控制;MQTT 主题、原始 CAN 帧 JSON 合约和有线/无线共用安全
|
||||
有线 ZLG 与本机 NanoMQ 无线链路使用相同的通道角色:CAN0 只对应副驾座椅,
|
||||
CAN1 对应空调、车窗、灯光、遮阳帘、主驾座椅等其余车辆状态和控制。车辆控制
|
||||
后端不允许回退到 CAN0;MQTT 主题、原始 CAN 帧 JSON 合约和有线/无线共用安全
|
||||
门禁见同一文档。
|
||||
|
||||
需要在终端独立核对双通道状态或下发一条明确的白名单语义命令时,可构建
|
||||
`can_read_trans`。该程序由同一个 C++ 进程持有 ZLG 设备:CAN0 监听并控制副驾
|
||||
座椅固件原生协议,CAN1 监听并控制其余车辆矩阵协议;默认只监听、默认不启用
|
||||
适配器内部终端电阻,也不提供任意原始帧注入入口。`console --execute` 可在同一
|
||||
设备会话中持续接收状态并交互下发 CAN0/CAN1 白名单命令。构建、命令示例、反馈
|
||||
判定和安全限制见 [can_read_trans 双通道检测与控制](docs/workflows/can-read-trans.md)。
|
||||
|
||||
## 认证与公网体验
|
||||
|
||||
公网演示必须启用 `.env.auth` 中的服务端认证。密码使用 PBKDF2-SHA256 哈希保存;认证成功后签发有有效期的 HMAC 会话 Cookie,Cookie 使用 `HttpOnly` 和 `SameSite=Strict`,经 HTTPS 代理时自动增加 `Secure`。登录失败按来源 IP 限流,GPT 请求按体验账号限制每分钟调用次数。未登录访问页面会跳转 `/login`,未登录 API 请求返回 `401`。
|
||||
|
||||
+330
-77
@@ -75,6 +75,7 @@ const state = {
|
||||
modeDraftCommand: "",
|
||||
lastSuccessfulUtterance: "",
|
||||
modeEditor: null,
|
||||
directCanCommandPending: false,
|
||||
};
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
@@ -2477,8 +2478,8 @@ function twinDeviceForSeat(seat, actuatorState = state.seatTwin?.actuatorState |
|
||||
...device,
|
||||
position: {
|
||||
...position,
|
||||
// The seat-domain compatibility key is cushion_tilt, but seat-ota
|
||||
// board axis 1 is physical height. Keep the 3D cushion neutral and
|
||||
// The seat-domain compatibility key is cushion_tilt, but the native
|
||||
// CAN0 firmware axis 1 is physical height. Keep the 3D cushion neutral and
|
||||
// animate/display the firmware feedback as height.
|
||||
cushion_tilt: { position_pct: 0, position_valid: true },
|
||||
height: seatHeight,
|
||||
@@ -4322,6 +4323,33 @@ function selectedVehicleTransport() {
|
||||
|| null;
|
||||
}
|
||||
|
||||
function isHardwareExecutionReady() {
|
||||
return Boolean(
|
||||
state.runtime?.hardware?.configured || selectedVehicleTransport()?.configured,
|
||||
);
|
||||
}
|
||||
|
||||
function hardwareExecutionIssue(
|
||||
hardware = state.runtime?.hardware || {},
|
||||
transport = selectedVehicleTransport() || {},
|
||||
) {
|
||||
const stateError = String(transport?.state_sync?.last_error || "").trim();
|
||||
if (/VCI_OpenDevice failed/i.test(stateError)) {
|
||||
return "ZLG 设备打开失败:请检查 USB 读写权限,或关闭占用适配器的其他程序";
|
||||
}
|
||||
if (stateError) return stateError;
|
||||
if (transport.state_configured === true && transport.configured !== true) {
|
||||
return "CAN 状态监听已配置,但真实控制授权尚未配置";
|
||||
}
|
||||
if (state.controlTransport === "wireless_mqtt") {
|
||||
return "无线 MQTT 控制后端尚未配置";
|
||||
}
|
||||
if (hardware.enabled === true && hardware.adapter_configured !== true) {
|
||||
return "副驾 CH0 CAN 适配器尚未配置";
|
||||
}
|
||||
return "ZLG 有线 CAN 后端尚未通过执行配置校验";
|
||||
}
|
||||
|
||||
function isDriverOnlyCh1Runtime() {
|
||||
const transport = selectedVehicleTransport();
|
||||
return transport?.configured === true
|
||||
@@ -4342,7 +4370,9 @@ function renderControlTransport() {
|
||||
// shown as offline, but it must never disable or silently change the
|
||||
// operator's wired/wireless selection.
|
||||
button.disabled = false;
|
||||
button.dataset.connectionState = runtime?.state_sync?.state === "online" ? "online" : "offline";
|
||||
button.dataset.connectionState = ["online", "ready"].includes(runtime?.state_sync?.state)
|
||||
? "online"
|
||||
: "offline";
|
||||
button.title = runtime?.state_configured === true
|
||||
? runtime?.state_sync?.last_error || ""
|
||||
: `${id === "wireless_mqtt" ? "无线 MQTT" : "有线 CAN"} 当前未配置(仍可手动选择)`;
|
||||
@@ -4361,8 +4391,16 @@ function renderControlTransport() {
|
||||
const connection = selected?.state_sync?.state === "online" ? "已连接" : "未连接";
|
||||
note.textContent = `MQTT ${connection} · 状态 ${selected?.condition_topic || "n7/n7-headunit/can/state"} · 控制 ${selected?.control_topic || "n7/n7-headunit/can/command"}`;
|
||||
} else {
|
||||
const connection = selected?.state_sync?.state === "online" ? "已连接" : "未连接";
|
||||
note.textContent = `ZLG 有线 ${connection} · CH${selected?.channel ?? 1} · CAN FD 500k/2M`;
|
||||
const connection = selected?.state_sync?.state === "online"
|
||||
? "已连接"
|
||||
: selected?.state_sync?.state === "ready"
|
||||
? "已配置"
|
||||
: "未连接";
|
||||
const dualChannel = selected?.target_policy
|
||||
=== "dual_channel_wired_can0_passenger_can1_vehicle";
|
||||
note.textContent = dualChannel
|
||||
? `ZLG 有线 ${connection} · CAN0 副驾经典 CAN 500k · CAN1 车辆 CAN FD 500k/2M`
|
||||
: `ZLG 有线 ${connection} · CH${selected?.channel ?? 1} · CAN FD 500k/2M`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4444,11 +4482,7 @@ function updateSeatFeedbackModeLabel() {
|
||||
}
|
||||
|
||||
function selectExecutionMode(mode, { remember = true } = {}) {
|
||||
const selectedTransport = selectedVehicleTransport();
|
||||
const hardwareExecutionReady = Boolean(
|
||||
state.runtime?.hardware?.configured || selectedTransport?.configured,
|
||||
);
|
||||
if (mode === "hardware" && !hardwareExecutionReady) return false;
|
||||
const hardwareExecutionReady = isHardwareExecutionReady();
|
||||
if (mode === "simulated_hardware" && !state.runtime?.simulated_hardware?.enabled) return false;
|
||||
if (remember) state.preferredExecutionMode = mode;
|
||||
state.executionMode = mode;
|
||||
@@ -4475,6 +4509,9 @@ function selectExecutionMode(mode, { remember = true } = {}) {
|
||||
? "真实执行"
|
||||
: simulatedHardware ? "运行故障仿真" : "生成并执行";
|
||||
}
|
||||
if (remember && mode === "hardware" && !hardwareExecutionReady) {
|
||||
showToast(`已切换到真实台架;当前不可执行:${hardwareExecutionIssue()}`);
|
||||
}
|
||||
updateSeatFeedbackModeLabel();
|
||||
refreshIcons();
|
||||
return true;
|
||||
@@ -5131,7 +5168,9 @@ function modeEditorReadableCommands(flow) {
|
||||
Object.values(plans).forEach((plan) => {
|
||||
modeEditorPlanCommands(plan).forEach((item) => {
|
||||
const debugCommand = String(item?.signals?.debug_command || item?.message || "CAN frame");
|
||||
const channel = modeEditorCanId(item?.signals?.frame_id) === "0x6A0"
|
||||
const channel = new Set(["0x29E", "0x300", "0x3CE"]).has(
|
||||
modeEditorCanId(item?.signals?.frame_id),
|
||||
)
|
||||
? "CAN0"
|
||||
: "CAN1";
|
||||
const readable = { channel, category: modeEditorCommandCategory(debugCommand), command: debugCommand };
|
||||
@@ -5797,7 +5836,13 @@ async function loadRuntime() {
|
||||
syncReasoningControls();
|
||||
$("#matrixCount").textContent = `${state.runtime.matrix.messages} / ${state.runtime.matrix.signals}`;
|
||||
const hardwareButton = $("#hardwareMode");
|
||||
hardwareButton.disabled = !hardwareExecutionReady;
|
||||
// “真实台架”是操作员手动选择的执行目标。硬件暂不可用时仍允许
|
||||
// 选择该目标并展示诊断信息;真正的控制提交继续由后端安全门拦截。
|
||||
hardwareButton.disabled = false;
|
||||
hardwareButton.dataset.executionState = hardwareExecutionReady ? "available" : "blocked";
|
||||
hardwareButton.title = hardwareExecutionReady
|
||||
? "真实台架已通过后端执行配置校验"
|
||||
: `可手动选择真实台架;当前不可执行:${hardwareExecutionIssue(hardware, vehicleCan)}`;
|
||||
const simulation = state.runtime.simulated_hardware || {};
|
||||
$("#simulationMode").disabled = !simulation.enabled;
|
||||
$("#simulationMode").title = simulation.enabled
|
||||
@@ -5821,7 +5866,7 @@ async function loadRuntime() {
|
||||
? `${mqttDualChannel ? "MQTT CAN0 副驾座椅 · MQTT CAN1 其余车辆" : `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s`} · ${vehicleRouteText}${mqttDualChannel ? " · 远端自动烧录禁用" : ` · ${profileText} · ${flashText}`}${accessText}${concurrencyText}`
|
||||
: seatHardwareReady
|
||||
? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · 车端 CAN 未启用,其他领域保持虚拟执行${accessText}${concurrencyText}`
|
||||
: `真实执行已锁定:${profileText};当前${mqttSelected ? "无线 MQTT" : "有线 CAN"}控制未通过后端审批配置`;
|
||||
: `可手动选择真实台架;当前不可执行:${hardwareExecutionIssue(hardware, vehicleCan)}`;
|
||||
$("#hardwareAliasNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
|
||||
? `${mqttSelected ? "MQTT" : "CH1"} → 车身 CAN / 主驾座椅 · CH0 与副驾 T2G 路径禁止`
|
||||
: linkedHardwareReady
|
||||
@@ -5838,9 +5883,7 @@ async function loadRuntime() {
|
||||
: "已阻断";
|
||||
$("#hardwareCoverage").className = hardwareExecutionReady ? "available" : "blocked";
|
||||
const preferredMode = state.preferredExecutionMode || DEFAULT_EXECUTION_MODE;
|
||||
const modeAvailable = preferredMode === "hardware"
|
||||
? hardwareExecutionReady
|
||||
: preferredMode === "simulated_hardware"
|
||||
const modeAvailable = preferredMode === "simulated_hardware"
|
||||
? simulation.enabled
|
||||
: true;
|
||||
selectExecutionMode(modeAvailable ? preferredMode : "dry_run", { remember: false });
|
||||
@@ -6724,6 +6767,7 @@ const CONFIG_CAPABILITY_LABELS = {
|
||||
"window.set_motion": "控制车窗动作",
|
||||
"window.set_position": "设置车窗开度",
|
||||
"outlet.set_position_percent": "设置电动风口位置",
|
||||
"outlet.set_switch_state": "开关单个电动风口",
|
||||
"outlet.set_calibrated_position": "设置电动风口标定位置",
|
||||
"interior_light.set_indoor_power": "设置室内灯",
|
||||
"interior_light.set_wall_power": "设置壁灯",
|
||||
@@ -6990,6 +7034,38 @@ function renderCode(result) {
|
||||
}
|
||||
}
|
||||
|
||||
function operationVerificationStatus(item = {}) {
|
||||
const explicit = String(item.telemetry?.verification_status || "");
|
||||
if (["verified_success", "submitted_unverified", "failed_or_blocked"].includes(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
if (item.status !== "succeeded") return "failed_or_blocked";
|
||||
if (item.telemetry?.physical_effect_confirmed === true || item.telemetry?.feedback_frame) {
|
||||
return "verified_success";
|
||||
}
|
||||
if (item.telemetry?.command_frame
|
||||
|| ["submitted", "queued", "published"].includes(item.telemetry?.delivery_status)) {
|
||||
return "submitted_unverified";
|
||||
}
|
||||
return "verified_success";
|
||||
}
|
||||
|
||||
function executionVerificationStatus(result = {}) {
|
||||
const explicit = String(
|
||||
result.execution_outcome
|
||||
|| result.execution_report?.spec?.verification_status
|
||||
|| "",
|
||||
);
|
||||
if (["verified_success", "submitted_unverified", "failed_or_blocked"].includes(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
const operations = result.execution_report?.spec?.results || [];
|
||||
const statuses = operations.map(operationVerificationStatus);
|
||||
if (statuses.includes("failed_or_blocked")) return "failed_or_blocked";
|
||||
if (statuses.includes("submitted_unverified")) return "submitted_unverified";
|
||||
return "verified_success";
|
||||
}
|
||||
|
||||
function renderExecution(result) {
|
||||
const summary = $("#executionSummary");
|
||||
const list = $("#resultList");
|
||||
@@ -7010,9 +7086,11 @@ function renderExecution(result) {
|
||||
refreshIcons();
|
||||
return;
|
||||
}
|
||||
const verificationStatus = executionVerificationStatus(result);
|
||||
const submittedUnverified = verificationStatus === "submitted_unverified";
|
||||
const reportSucceeded = report.spec.status === "succeeded";
|
||||
const partiallySucceeded = result.status === "partially_succeeded";
|
||||
summary.className = `execution-summary ${reportSucceeded ? "succeeded" : partiallySucceeded ? "partial" : "failed"}`;
|
||||
summary.className = `execution-summary ${submittedUnverified || partiallySucceeded ? "partial" : reportSucceeded ? "succeeded" : "failed"}`;
|
||||
const presetSave = Boolean(result.preset_config);
|
||||
const modeSave = Boolean(result.mode_profile);
|
||||
const simulatedHil = report.metadata?.labels?.seat_feedback === "simulated_hardware";
|
||||
@@ -7023,14 +7101,12 @@ function renderExecution(result) {
|
||||
const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware");
|
||||
const succeededOperations = operationResults.filter((item) => item.status === "succeeded");
|
||||
const failedOperations = operationResults.filter((item) => item.status === "failed");
|
||||
const mqttPublishedOperations = operationResults.filter(
|
||||
(item) => item.telemetry?.delivery_status === "published",
|
||||
const submittedOperations = operationResults.filter(
|
||||
(item) => operationVerificationStatus(item) === "submitted_unverified",
|
||||
);
|
||||
const physicallyConfirmedOperations = operationResults.filter(
|
||||
(item) => item.telemetry?.physical_effect_confirmed === true,
|
||||
);
|
||||
const mqttPublishedUnconfirmed = result.control_transport === "wireless_mqtt"
|
||||
&& mqttPublishedOperations.length > physicallyConfirmedOperations.length;
|
||||
const succeededDomains = [...new Set(succeededOperations.map((item) => item.domain))];
|
||||
const failedDomains = [...new Set(failedOperations.map((item) => item.domain))];
|
||||
let executionDetail = modeSave
|
||||
@@ -7040,8 +7116,10 @@ function renderExecution(result) {
|
||||
: simulatedHil
|
||||
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
|
||||
: physical
|
||||
? result.control_transport === "wireless_mqtt"
|
||||
? `${executedDomains.join(" / ")} 已通过 MQTT 发布 ${mqttPublishedOperations.length || succeededOperations.length} 个白名单控制步骤${mqttPublishedUnconfirmed ? ";车辆效果尚未确认,以状态话题回读为准" : `,并确认 ${physicallyConfirmedOperations.length || succeededOperations.length} 个车辆状态`}`
|
||||
? submittedUnverified
|
||||
? `${executedDomains.join(" / ")} 已通过${result.control_transport === "wireless_mqtt" ? " MQTT" : "有线 CAN"}提交 ${submittedOperations.length || succeededOperations.length} 个白名单控制步骤;车辆效果尚未确认,以实时状态回读为准`
|
||||
: result.control_transport === "wireless_mqtt"
|
||||
? `${executedDomains.join(" / ")} 已向 MQTT 提交 ${succeededOperations.length} 个白名单控制步骤,并确认 ${physicallyConfirmedOperations.length || succeededOperations.length} 个车辆状态`
|
||||
: selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only"
|
||||
? `${executedDomains.join(" / ")} 已通过 CH1 下发,并以车端状态字段确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
|
||||
: `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
|
||||
@@ -7055,7 +7133,7 @@ function renderExecution(result) {
|
||||
if (result.deferred_requests?.length) {
|
||||
executionDetail += ";未接入域请求已保留为 deferred request";
|
||||
}
|
||||
if (!reportSucceeded && !partiallySucceeded) {
|
||||
if (verificationStatus === "failed_or_blocked" && !partiallySucceeded) {
|
||||
const failure = report.spec.results.find((item) => item.status === "failed");
|
||||
const failureTelemetry = failure?.telemetry || {};
|
||||
executionDetail = simulatedHil
|
||||
@@ -7081,26 +7159,31 @@ function renderExecution(result) {
|
||||
: presetSave ? "姿态预设已保存"
|
||||
: simulatedHil ? "模拟台架执行成功"
|
||||
: physical
|
||||
? mqttPublishedUnconfirmed ? "MQTT 指令已发布" : "真实台架执行成功"
|
||||
? submittedUnverified
|
||||
? `${result.control_transport === "wireless_mqtt" ? "MQTT" : "CAN"} 指令已提交,等待状态确认`
|
||||
: "真实台架执行并验证成功"
|
||||
: "虚拟执行成功"
|
||||
: partiallySucceeded ? (physical ? "真实台架部分执行成功" : "部分执行成功")
|
||||
: simulatedHil ? "模拟故障已触发" : physical ? "真实台架执行失败" : "虚拟执行失败";
|
||||
const executionIcon = reportSucceeded
|
||||
const executionIcon = submittedUnverified
|
||||
? "circle-alert"
|
||||
: reportSucceeded
|
||||
? ((presetSave || modeSave) ? "bookmark-check" : "circle-check-big")
|
||||
: partiallySucceeded ? "circle-alert" : "circle-x";
|
||||
summary.innerHTML = `<i data-lucide="${executionIcon}"></i><div><strong>${executionTitle}</strong><span>${executionDetail}</span></div>`;
|
||||
$("#resultNavMeta").textContent = reportSucceeded
|
||||
? mqttPublishedUnconfirmed
|
||||
? `${mqttPublishedOperations.length} 项已发布 · MQTT`
|
||||
? submittedUnverified
|
||||
? `${submittedOperations.length || succeededOperations.length} 项已提交 · 等待状态确认`
|
||||
: `${report.spec.results.length} 项完成 · ${simulatedHil ? "SIM-HIL" : physical ? "HIL" : "DRY-RUN"}`
|
||||
: partiallySucceeded ? `${succeededOperations.length} 项成功 / ${failedOperations.length} 项失败 · 查看明细`
|
||||
: "执行失败 · 查看回读";
|
||||
$("#resultPanelState").textContent = reportSucceeded ? "SUCCEEDED" : partiallySucceeded ? "PARTIAL" : "FAILED";
|
||||
$("#resultPanelState").className = `workspace-panel-state ${reportSucceeded ? "succeeded" : partiallySucceeded ? "partial" : "failed"}`;
|
||||
$("#resultPanelState").textContent = submittedUnverified ? "SUBMITTED" : reportSucceeded ? "VERIFIED" : partiallySucceeded ? "PARTIAL" : "FAILED";
|
||||
$("#resultPanelState").className = `workspace-panel-state ${submittedUnverified || partiallySucceeded ? "partial" : reportSucceeded ? "succeeded" : "failed"}`;
|
||||
report.spec.results.forEach((item) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "result-item";
|
||||
row.innerHTML = `<i data-lucide="${item.status === "succeeded" ? "circle-check" : "circle-x"}"></i>`;
|
||||
const itemVerification = operationVerificationStatus(item);
|
||||
row.innerHTML = `<i data-lucide="${itemVerification === "verified_success" ? "circle-check" : itemVerification === "submitted_unverified" ? "circle-alert" : "circle-x"}"></i>`;
|
||||
const text = document.createElement("div");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = item.ref;
|
||||
@@ -7144,7 +7227,7 @@ function authoritativeCh1StatePatch(value) {
|
||||
function physicalSeatStatePatch(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const source = value.source || value.feedback_source;
|
||||
if (["physical_can", "physical_can_ch0", "physical_partial"].includes(source)) {
|
||||
if (["physical_can", "physical_can_ch0", "wireless_mqtt_ch0", "physical_partial"].includes(source)) {
|
||||
return value;
|
||||
}
|
||||
const patch = {};
|
||||
@@ -7249,11 +7332,7 @@ function renderResult(result) {
|
||||
state.result = result;
|
||||
const reportMode = result.execution_report?.spec?.mode;
|
||||
const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback;
|
||||
const mqttPublishedUnconfirmed = result.control_transport === "wireless_mqtt"
|
||||
&& (result.execution_report?.spec?.results || []).some(
|
||||
(item) => item.telemetry?.delivery_status === "published"
|
||||
&& item.telemetry?.physical_effect_confirmed !== true,
|
||||
);
|
||||
const submittedUnverified = executionVerificationStatus(result) === "submitted_unverified";
|
||||
if (feedbackSource === "simulated_hardware") {
|
||||
state.seatFeedbackSource = "simulated_hardware";
|
||||
if (result.actuator_state?.passenger) state.seatFeedbackSources.passenger = "simulated_hardware";
|
||||
@@ -7288,10 +7367,10 @@ function renderResult(result) {
|
||||
? "长期模式配置已保存"
|
||||
: result.preset_config
|
||||
? "座椅姿态预设已保存"
|
||||
: result.scene_config
|
||||
? `${domains.length} 域场景闭环完成`
|
||||
: mqttPublishedUnconfirmed
|
||||
? `${domainLabels[domains[0]] || "车舱"}控制指令已发布`
|
||||
: submittedUnverified
|
||||
? `${domains.length > 1 ? `${domains.length} 域场景` : domainLabels[domains[0]] || "车舱"}控制指令已提交`
|
||||
: result.scene_config
|
||||
? `${domains.length} 域场景闭环完成`
|
||||
: `${domainLabels[domains[0]] || "车舱"}单域闭环完成`
|
||||
: "请求未执行";
|
||||
const conversationLabel = result.conversation_id?.slice(-8) || "no-context";
|
||||
@@ -7301,8 +7380,8 @@ function renderResult(result) {
|
||||
$("#requestMeta").textContent += ` / reasoning:${result.reasoning_effort_used}`;
|
||||
}
|
||||
setRunStatus(
|
||||
partiallySucceeded ? "partial" : executionSucceeded ? "succeeded" : "failed",
|
||||
result.status.toUpperCase(),
|
||||
partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed",
|
||||
submittedUnverified ? "SUBMITTED_UNVERIFIED" : result.status.toUpperCase(),
|
||||
);
|
||||
renderDecision(result);
|
||||
renderTrace(result.trace_events || []);
|
||||
@@ -7324,6 +7403,119 @@ function renderResult(result) {
|
||||
window.dispatchEvent(new CustomEvent("cognitive-agent:result", { detail: result }));
|
||||
}
|
||||
|
||||
async function postControlPhase(path, body, executionMode, onEvent = null) {
|
||||
const streaming = typeof onEvent === "function";
|
||||
const response = await apiRequest(path, {
|
||||
method: "POST",
|
||||
timeoutMs: requestTimeoutForExecutionMode(executionMode),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(streaming ? { Accept: "application/x-ndjson" } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.replace("/login");
|
||||
return null;
|
||||
}
|
||||
if (streaming && response.ok && response.body?.getReader) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffered = "";
|
||||
let resultPayload = null;
|
||||
const handleLine = (line) => {
|
||||
if (!line.trim()) return;
|
||||
const phaseEvent = JSON.parse(line);
|
||||
if (phaseEvent.type === "error") {
|
||||
const requestError = new Error(
|
||||
phaseEvent.message || phaseEvent.error || "流式控制请求失败",
|
||||
);
|
||||
requestError.code = phaseEvent.error;
|
||||
requestError.outcome = phaseEvent.outcome;
|
||||
requestError.detail = phaseEvent.detail;
|
||||
requestError.retryAfter = Number(phaseEvent.retry_after_seconds || 0);
|
||||
throw requestError;
|
||||
}
|
||||
if (phaseEvent.type === "result") {
|
||||
resultPayload = phaseEvent.payload;
|
||||
} else {
|
||||
onEvent(phaseEvent);
|
||||
}
|
||||
};
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffered += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||
const lines = buffered.split("\n");
|
||||
buffered = lines.pop() || "";
|
||||
lines.forEach(handleLine);
|
||||
if (done) break;
|
||||
}
|
||||
if (buffered.trim()) handleLine(buffered);
|
||||
if (!resultPayload) throw new Error("流式控制请求未返回完整结果");
|
||||
return resultPayload;
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
const requestError = new Error(payload.message || payload.error || `HTTP ${response.status}`);
|
||||
requestError.code = payload.error;
|
||||
requestError.retryAfter = Number(
|
||||
payload.retry_after_seconds || response.headers.get("Retry-After") || 0,
|
||||
);
|
||||
throw requestError;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function executeCanCommands(canCommands, { deviceName = "车辆设备" } = {}) {
|
||||
const commands = Array.isArray(canCommands)
|
||||
? canCommands.map((command) => String(command || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
if (!commands.length) throw new Error("没有可发送的 CAN 白名单控制指令");
|
||||
if (state.directCanCommandPending) throw new Error("上一组 CAN 控制指令仍在执行,请稍候");
|
||||
|
||||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||||
if (state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
&& !isLocalDecisionHost()
|
||||
&& state.hardwareAccess?.state !== "approved") {
|
||||
throw new Error(state.hardwareAccess?.state === "pending"
|
||||
? "申请仍在等待本地审批"
|
||||
: "请先申请并获得本地真实台架使用权");
|
||||
}
|
||||
|
||||
state.directCanCommandPending = true;
|
||||
try {
|
||||
const payload = await postControlPhase(
|
||||
"/api/can/commands",
|
||||
{
|
||||
commands,
|
||||
client_type: "web_console_device_control",
|
||||
conversation_id: state.conversationId,
|
||||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||||
execution_mode: state.executionMode,
|
||||
control_transport: state.controlTransport,
|
||||
...(state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
...(state.executionMode === "simulated_hardware"
|
||||
? { simulation_fault: $("#simulationFault").value }
|
||||
: {}),
|
||||
},
|
||||
state.executionMode,
|
||||
);
|
||||
if (!payload) throw new Error("CAN 控制请求未返回结果");
|
||||
renderResult(payload);
|
||||
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
|
||||
showToast(`${deviceName} CAN 控制已发送`);
|
||||
return payload;
|
||||
} finally {
|
||||
state.directCanCommandPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRequest(event) {
|
||||
event.preventDefault();
|
||||
const utterance = $("#utterance").value.trim();
|
||||
@@ -7336,6 +7528,7 @@ async function submitRequest(event) {
|
||||
}
|
||||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||||
if (state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
&& !isLocalDecisionHost()
|
||||
&& state.hardwareAccess?.state !== "approved") {
|
||||
@@ -7360,39 +7553,93 @@ async function submitRequest(event) {
|
||||
setRunStatus("processing", "PROCESSING");
|
||||
$("#resultTitle").textContent = "正在执行车舱编排";
|
||||
try {
|
||||
const response = await apiRequest("/api/requests", {
|
||||
method: "POST",
|
||||
timeoutMs: requestTimeoutForExecutionMode(state.executionMode),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
utterance,
|
||||
client_type: "web_console",
|
||||
provider: state.provider,
|
||||
conversation_id: state.conversationId,
|
||||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||||
execution_mode: state.executionMode,
|
||||
control_transport: state.controlTransport,
|
||||
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
|
||||
...(state.executionMode === "hardware" && !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
...(state.executionMode === "simulated_hardware"
|
||||
? { simulation_fault: $("#simulationFault").value }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (response.status === 401) {
|
||||
window.location.replace("/login");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const requestError = new Error(payload.message || payload.error || `HTTP ${response.status}`);
|
||||
requestError.code = payload.error;
|
||||
requestError.retryAfter = Number(
|
||||
payload.retry_after_seconds || response.headers.get("Retry-After") || 0,
|
||||
const requestBody = {
|
||||
utterance,
|
||||
client_type: "web_console",
|
||||
provider: state.provider,
|
||||
conversation_id: state.conversationId,
|
||||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||||
execution_mode: state.executionMode,
|
||||
control_transport: state.controlTransport,
|
||||
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
|
||||
...(state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
...(state.executionMode === "simulated_hardware"
|
||||
? { simulation_fault: $("#simulationFault").value }
|
||||
: {}),
|
||||
};
|
||||
let payload;
|
||||
let supplementWarning = "";
|
||||
const onControlPhaseEvent = (phaseEvent) => {
|
||||
if (phaseEvent.type !== "validated_commands" || !phaseEvent.payload) return;
|
||||
window.dispatchEvent(new CustomEvent(
|
||||
"cognitive-agent:validated-can-commands",
|
||||
{ detail: phaseEvent.payload },
|
||||
));
|
||||
};
|
||||
if (state.provider === "openai") {
|
||||
const preview = await postControlPhase(
|
||||
"/api/requests/preview",
|
||||
requestBody,
|
||||
state.executionMode,
|
||||
onControlPhaseEvent,
|
||||
);
|
||||
throw requestError;
|
||||
if (!preview) return;
|
||||
renderResult(preview);
|
||||
completedResult = preview;
|
||||
if (preview.supplement_required) {
|
||||
setRunStatus("processing", "CLOUD SUPPLEMENT");
|
||||
$("#resultTitle").textContent = "本地指令已处理,正在补充云端解析";
|
||||
try {
|
||||
payload = await postControlPhase(
|
||||
`/api/requests/${preview.request_id}/supplement`,
|
||||
{
|
||||
client_type: "web_console",
|
||||
provider: state.provider,
|
||||
reasoning_effort: state.reasoningEffort,
|
||||
...(state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
},
|
||||
state.executionMode,
|
||||
onControlPhaseEvent,
|
||||
);
|
||||
if (!payload) return;
|
||||
} catch (error) {
|
||||
const localCommandCount = Object.values(preview.function_configs || {})
|
||||
.reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0);
|
||||
if (!localCommandCount) throw error;
|
||||
payload = {
|
||||
...preview,
|
||||
status: "partially_succeeded",
|
||||
supplement_required: false,
|
||||
cloud_supplement_error: {
|
||||
code: error.code || "cloud_supplement_failed",
|
||||
message: error.message,
|
||||
},
|
||||
decision_summary: [
|
||||
...(preview.decision_summary || []),
|
||||
`云端补充失败:${error.message};本地规则命令已保留且不会撤销。`,
|
||||
],
|
||||
};
|
||||
supplementWarning = "本地规则命令已执行;云端补充失败,已保留本地结果";
|
||||
}
|
||||
} else {
|
||||
payload = preview;
|
||||
}
|
||||
} else {
|
||||
payload = await postControlPhase(
|
||||
"/api/requests",
|
||||
requestBody,
|
||||
state.executionMode,
|
||||
onControlPhaseEvent,
|
||||
);
|
||||
if (!payload) return;
|
||||
}
|
||||
renderResult(payload);
|
||||
completedResult = payload;
|
||||
@@ -7405,17 +7652,20 @@ async function submitRequest(event) {
|
||||
setModeExecutionState(submittedModeProfileId, completed ? "succeeded" : "failed");
|
||||
}
|
||||
const simulated = payload.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
|
||||
showToast(completed
|
||||
const submittedUnverified = executionVerificationStatus(payload) === "submitted_unverified";
|
||||
showToast(supplementWarning || (completed
|
||||
? (payload.mode_profile
|
||||
? `${payload.mode_profile.name}已创建并添加到功能模式`
|
||||
: payload.status === "partially_succeeded"
|
||||
? "已执行可用设备;部分设备未完成,详见执行结果"
|
||||
: simulated
|
||||
? "模拟台架执行完成"
|
||||
: submittedUnverified
|
||||
? "CAN 控制指令已提交;等待车辆实时状态确认"
|
||||
: payload.execution_report?.spec?.mode === "hil"
|
||||
? "真实台架执行与反馈确认已完成"
|
||||
: "可执行动作的 dry-run 已完成")
|
||||
: simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行");
|
||||
: simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行"));
|
||||
} catch (error) {
|
||||
completedError = error;
|
||||
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
|
||||
@@ -7519,6 +7769,9 @@ window.CognitiveAgentConsole = {
|
||||
if (autoSubmit) $("#requestForm").requestSubmit();
|
||||
return true;
|
||||
},
|
||||
executeCanCommands(canCommands, options = {}) {
|
||||
return executeCanCommands(canCommands, options);
|
||||
},
|
||||
getExecutionMode() {
|
||||
return state.executionMode;
|
||||
},
|
||||
|
||||
@@ -7201,6 +7201,30 @@ body.device-power-drawer-open {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-source {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 5px;
|
||||
color: #2366a1;
|
||||
background: #e4f0fb;
|
||||
border: 1px solid rgba(35, 102, 161, 0.14);
|
||||
border-radius: 999px;
|
||||
font-size: 7px;
|
||||
font-weight: 850;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-source[data-parse-source="local_rule"] {
|
||||
color: #08766f;
|
||||
background: #d9f3ef;
|
||||
border-color: rgba(8, 118, 111, 0.14);
|
||||
}
|
||||
|
||||
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-source[data-parse-source="direct_control"] {
|
||||
color: #7a4b00;
|
||||
background: #fff0c7;
|
||||
border-color: rgba(122, 75, 0, 0.16);
|
||||
}
|
||||
|
||||
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="CAN0"] {
|
||||
background: #f7f4ec;
|
||||
}
|
||||
|
||||
+309
-80
@@ -21,6 +21,32 @@
|
||||
};
|
||||
|
||||
let reasoningPickerControl = null;
|
||||
let validatedCommandsVisible = false;
|
||||
let validatedCommandContext = { requestId: "", utterance: "" };
|
||||
|
||||
function resetValidatedCommandContext() {
|
||||
validatedCommandsVisible = false;
|
||||
validatedCommandContext = { requestId: "", utterance: "" };
|
||||
}
|
||||
|
||||
function rememberValidatedCommandContext(result = {}) {
|
||||
validatedCommandContext = {
|
||||
requestId: String(result.request_id || ""),
|
||||
utterance: String(result.utterance || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function validatedCommandsBelongToTask(task = {}) {
|
||||
if (!validatedCommandsVisible) return false;
|
||||
const result = task.result && typeof task.result === "object" ? task.result : {};
|
||||
const requestId = String(result.request_id || "");
|
||||
const utterance = String(task.utterance || result.utterance || "").trim();
|
||||
if (validatedCommandContext.requestId && requestId
|
||||
&& validatedCommandContext.requestId !== requestId) return false;
|
||||
if (validatedCommandContext.utterance && utterance
|
||||
&& validatedCommandContext.utterance !== utterance) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function make(tag, className, html) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -61,46 +87,78 @@
|
||||
const target = normalizedCanTarget(operation.target);
|
||||
const onOff = normalizedOnOff(parameters.state ?? parameters.power);
|
||||
const hyphenated = (value) => String(value || "").replaceAll("_", "-");
|
||||
const parseSource = String(operation.parse_source || "");
|
||||
const command = (category, text, channel = "CAN1", frame = "") => ({
|
||||
channel, category, command: text, ...(frame ? { frame } : {}),
|
||||
channel,
|
||||
category,
|
||||
command: text,
|
||||
...(frame ? { frame } : {}),
|
||||
...(parseSource ? { parseSource } : {}),
|
||||
});
|
||||
const hexByte = (value) => Number(value).toString(16).padStart(2, "0").toUpperCase();
|
||||
const otaCan0Frame = (opcode, selector, argument) => {
|
||||
const numeric = Number(argument) >>> 0;
|
||||
const argumentBytes = [0, 8, 16, 24].map((shift) => hexByte((numeric >>> shift) & 0xFF));
|
||||
return `0x6A0 · C7 <SEQ> ${hexByte(opcode)} ${hexByte(selector)} ${argumentBytes.join(" ")}`;
|
||||
const littleEndian16 = (value) => {
|
||||
const numeric = Number(value) & 0xFFFF;
|
||||
return `${hexByte(numeric & 0xFF)} ${hexByte((numeric >>> 8) & 0xFF)}`;
|
||||
};
|
||||
const nativeCan0Frame = (opcode, motor, direction, paramA, paramB) => {
|
||||
const control = (Number(motor) & 0x03) | ((Number(direction) & 0x03) << 2) | 0x10;
|
||||
return `0x300 · ${hexByte(opcode)} ${hexByte(control)} <SEQ_LO> <SEQ_HI> ${littleEndian16(paramA)} ${littleEndian16(paramB)}`;
|
||||
};
|
||||
|
||||
const isPassengerSeat = new Set(["passenger", "front-right"]).has(target);
|
||||
if (isPassengerSeat) {
|
||||
const passengerAxes = {
|
||||
"seat.set_longitudinal_position": ["副驾座椅前后调节", "longitudinal", 0],
|
||||
"seat.set_cushion_tilt": ["副驾座椅高度调节", "height", 1],
|
||||
"seat.set_longitudinal_position": ["副驾座椅前后调节", "horizontal", 0],
|
||||
"seat.set_cushion_tilt": ["副驾座椅高度调节", "basin", 1],
|
||||
"seat.set_backrest_recline": ["副驾座椅靠背调节", "backrest", 2],
|
||||
"seat.set_legrest_height": ["副驾座椅腿托调节", "legrest", 3],
|
||||
};
|
||||
const axis = passengerAxes[capability];
|
||||
const position = boundedInteger(parameters.position_pct, 0, 100);
|
||||
if (axis && position !== null) {
|
||||
const targetPermille = position * 10;
|
||||
const motion = operation.motion || {};
|
||||
const delta = boundedInteger(motion.delta_position_pct, 1, 100);
|
||||
const relativeDirections = {
|
||||
horizontal: { increase: "backward", decrease: "forward" },
|
||||
basin: { increase: "down", decrease: "up" },
|
||||
backrest: { increase: "backward", decrease: "forward" },
|
||||
legrest: { increase: "up", decrease: "down" },
|
||||
};
|
||||
const relativeDirection = relativeDirections[axis[1]]?.[motion.direction];
|
||||
return command(
|
||||
axis[0],
|
||||
`seat passenger ${axis[1]} position ${position}`,
|
||||
motion.mode === "relative" && relativeDirection && delta !== null
|
||||
? `seat passenger ${axis[1]} relative ${relativeDirection} ${delta}`
|
||||
: `seat passenger ${axis[1]} position ${position}`,
|
||||
"CAN0",
|
||||
otaCan0Frame(0x64, axis[2], targetPermille),
|
||||
position === 0
|
||||
? nativeCan0Frame(0x0B, axis[2], 0, 0x4B52, 120)
|
||||
: nativeCan0Frame(0x01, axis[2], 0, position * 100, 30000),
|
||||
);
|
||||
}
|
||||
if (new Set(["seat.set_ventilation", "seat.set_heating"]).has(capability)) {
|
||||
const feature = capability === "seat.set_heating" ? "heat" : "vent";
|
||||
const level = boundedInteger(parameters.level, 1, 3);
|
||||
const setting = onOff === "off"
|
||||
? "off"
|
||||
: ({ 1: "low", 2: "medium", 3: "high" })[level];
|
||||
if (setting) {
|
||||
return command(
|
||||
"副驾座椅通风/加热",
|
||||
`seat passenger ${feature} ${setting}`,
|
||||
"CAN0",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (capability === "seat.start_dynamic_mode") {
|
||||
const mode = boundedInteger(parameters.mode_id, 1, 2);
|
||||
if (mode !== null) {
|
||||
const dynamic = mode === 1
|
||||
? { name: "rocking-horse", selector: 2 }
|
||||
: { name: "cradle", selector: 1 };
|
||||
const dynamic = mode === 1 ? "rocking-horse" : "cradle";
|
||||
return command(
|
||||
"副驾座椅动态模式",
|
||||
`seat passenger dynamic start ${dynamic.name}`,
|
||||
`seat passenger dynamic ${dynamic}`,
|
||||
"CAN0",
|
||||
otaCan0Frame(0x66, dynamic.selector, 300),
|
||||
nativeCan0Frame(0x0E, 0, 0, mode, 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -109,7 +167,7 @@
|
||||
"副驾座椅动态模式",
|
||||
"seat passenger dynamic stop",
|
||||
"CAN0",
|
||||
otaCan0Frame(0x63, 0xFF, 0),
|
||||
nativeCan0Frame(0x0F, 0, 0, 0, 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -167,6 +225,13 @@
|
||||
const mode = String(parameters.mode ?? parameters.cycle_mode ?? "");
|
||||
if (new Set(["internal", "external"]).has(mode)) return command("空调循环", `ac cycle ${mode}`);
|
||||
}
|
||||
if (capability === "outlet.set_switch_state") {
|
||||
const state = String(parameters.state ?? "");
|
||||
if (new Set(["driver-left", "driver-right", "passenger-left", "passenger-right"]).has(target)
|
||||
&& new Set(["open", "close"]).has(state)) {
|
||||
return command("单风口开关", `outlet ${target} ${state}`);
|
||||
}
|
||||
}
|
||||
if (capability === "outlet.set_position_percent") {
|
||||
const horizontal = boundedInteger(parameters.horizontal_pct, 0, 100);
|
||||
const vertical = boundedInteger(parameters.vertical_pct, 0, 100);
|
||||
@@ -271,7 +336,15 @@
|
||||
if (item) commands.push(item);
|
||||
});
|
||||
});
|
||||
return [...new Map(commands.map((item) => [`${item.channel}:${item.command}`, item])).values()];
|
||||
const unique = new Map();
|
||||
commands.forEach((item) => {
|
||||
const key = `${item.channel}:${item.command}`;
|
||||
const previous = unique.get(key);
|
||||
if (!previous || (previous.parseSource !== "local_rule" && item.parseSource === "local_rule")) {
|
||||
unique.set(key, item);
|
||||
}
|
||||
});
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
function renderParsedCanCommands(result, { message = "" } = {}) {
|
||||
@@ -285,9 +358,13 @@
|
||||
if (commands.length) {
|
||||
list.innerHTML = `<ol>${commands.map((item) => `
|
||||
<li data-can-channel="${escapeHtml(item.channel)}">
|
||||
<span><b class="v2-can-command-channel">${escapeHtml(item.channel)}</b>${escapeHtml(item.category)}</span>
|
||||
<span>
|
||||
<b class="v2-can-command-channel">${escapeHtml(item.channel)}</b>
|
||||
${item.parseSource ? `<b class="v2-can-command-source" data-parse-source="${escapeHtml(item.parseSource)}">${item.parseSource === "direct_control" ? "设备控件" : item.parseSource === "local_rule" ? "本地规则" : "云端模型"}</b>` : ""}
|
||||
${escapeHtml(item.category)}
|
||||
</span>
|
||||
<code>${escapeHtml(item.command)}</code>
|
||||
${item.frame ? `<small class="v2-can-command-frame">seat-ota · ${escapeHtml(item.frame)}</small>` : ""}
|
||||
${item.frame ? `<small class="v2-can-command-frame">固件原生 CAN0 · ${escapeHtml(item.frame)}</small>` : ""}
|
||||
</li>`).join("")}</ol>`;
|
||||
} else {
|
||||
list.innerHTML = `<p>${escapeHtml(message || "本次请求未生成已验证的 CAN 控制指令")}</p>`;
|
||||
@@ -295,6 +372,34 @@
|
||||
requestAnimationFrame(syncSidebarLogBaseline);
|
||||
}
|
||||
|
||||
function renderDirectCanCommands(commands, deviceName) {
|
||||
const panel = $("#v2CanCommandPanel");
|
||||
const list = $("#v2CanCommandList");
|
||||
const count = $("#v2CanCommandCount");
|
||||
if (!panel || !list || !count) return;
|
||||
const items = commands.map((command) => ({
|
||||
channel: command.startsWith("seat passenger ") ? "CAN0" : "CAN1",
|
||||
command,
|
||||
}));
|
||||
count.textContent = String(items.length);
|
||||
panel.dataset.state = "ready";
|
||||
list.innerHTML = `<ol>${items.map((item) => `
|
||||
<li data-can-channel="${item.channel}">
|
||||
<span>
|
||||
<b class="v2-can-command-channel">${item.channel}</b>
|
||||
<b class="v2-can-command-source" data-parse-source="direct_control">设备控件</b>
|
||||
${escapeHtml(deviceName)}
|
||||
</span>
|
||||
<code>${escapeHtml(item.command)}</code>
|
||||
</li>`).join("")}</ol>`;
|
||||
validatedCommandsVisible = true;
|
||||
validatedCommandContext = {
|
||||
requestId: "",
|
||||
utterance: items.map((item) => item.command).join(";"),
|
||||
};
|
||||
requestAnimationFrame(syncSidebarLogBaseline);
|
||||
}
|
||||
|
||||
function closeReasoningPicker() {
|
||||
reasoningPickerControl?.close();
|
||||
}
|
||||
@@ -602,7 +707,7 @@
|
||||
function seatLevelControlValue(seat, feature) {
|
||||
const text = seatFunctionValue(seat, feature);
|
||||
const level = text.match(/L(\d+)/i)?.[1];
|
||||
return /OFF|关闭|--/.test(text) ? "off" : level || "1";
|
||||
return /OFF|关闭|--/.test(text) ? "off" : level === "2" ? "low" : "high";
|
||||
}
|
||||
|
||||
function massageReadout() {
|
||||
@@ -614,34 +719,31 @@
|
||||
}
|
||||
|
||||
const levelOptions = [
|
||||
["off", "关闭"], ["1", "1 档"], ["2", "2 档"], ["3", "3 档"],
|
||||
["off", "关闭"], ["high", "1 档(高)"], ["low", "2 档(低)"],
|
||||
];
|
||||
const toggleOptions = [["off", "关闭"], ["on", "开启"]];
|
||||
const massageModeOptions = [
|
||||
["off", "关闭"], ["relax", "舒缓"], ["stretch", "伸展"], ["waist", "腰部"], ["wave", "波浪"],
|
||||
["off", "关闭"], ["relax", "放松"], ["stretch", "伸展"], ["slack", "松弛"], ["wave", "波浪"], ["pulse", "脉冲"],
|
||||
];
|
||||
const massageStrengthOptions = [
|
||||
["off", "关闭"], ["low", "低"], ["medium", "中"], ["high", "高"],
|
||||
["off", "关闭"], ["weak", "低"], ["medium", "中"], ["strong", "高"],
|
||||
];
|
||||
const colorIndexNames = ["皓月白", "冰川蓝", "松石绿", "星云紫", "琥珀橙", "玫瑰红"];
|
||||
const colorIndexOptions = Array.from({ length: 16 }, (_item, index) => [
|
||||
String(index), colorIndexNames[index] ? `${index} · ${colorIndexNames[index]}` : `色号 ${index}`,
|
||||
const colorIndexOptions = Array.from({ length: 127 }, (_item, index) => [
|
||||
String(index + 1), colorIndexNames[index] ? `${index + 1} · ${colorIndexNames[index]}` : `色号 ${index + 1}`,
|
||||
]);
|
||||
|
||||
const rangeControl = (key, label, value, min = 0, max = 100, step = 1, unit = "%", hint = "") => ({
|
||||
key, label, type: "range", value: clampControlNumber(value, min, max), min, max, step, unit, hint,
|
||||
});
|
||||
const selectControl = (key, label, value, options) => ({ key, label, type: "select", value, options });
|
||||
const hasControlValue = (values, key) => Object.hasOwn(values, key);
|
||||
|
||||
function deviceControlValue(deviceId, control) {
|
||||
const draft = displaySync.deviceDrafts[deviceId];
|
||||
return draft && Object.hasOwn(draft, control.key) ? draft[control.key] : control.value;
|
||||
}
|
||||
|
||||
function optionLabel(options, value) {
|
||||
return options.find(([optionValue]) => String(optionValue) === String(value))?.[1] || String(value);
|
||||
}
|
||||
|
||||
const syncedDevices = [
|
||||
{
|
||||
id: "passenger", name: "副驾座椅", icon: "armchair",
|
||||
@@ -652,7 +754,13 @@
|
||||
rangeControl("backrest_recline", "靠背", seatAxisValue("passenger", "backrest_recline")),
|
||||
rangeControl("legrest_height", "腿托", seatAxisValue("passenger", "legrest_height")),
|
||||
],
|
||||
command: (values) => `调节副驾座椅:前后${values.longitudinal}%,座椅高度${values.cushion_tilt}%,靠背${values.backrest_recline}%,腿托${values.legrest_height}%`,
|
||||
commands: (values) => [
|
||||
["longitudinal", "horizontal"],
|
||||
["cushion_tilt", "basin"],
|
||||
["backrest_recline", "backrest"],
|
||||
["legrest_height", "legrest"],
|
||||
].filter(([key]) => hasControlValue(values, key))
|
||||
.map(([key, axis]) => `seat passenger ${axis} position ${Math.round(Number(values[key]))}`),
|
||||
},
|
||||
{
|
||||
id: "driver", name: "主驾座椅", icon: "armchair",
|
||||
@@ -669,7 +777,22 @@
|
||||
selectControl("massage_strength", "按摩强度", /关闭|--/.test(massageStrength) ? "off" : massageStrength.toLowerCase(), massageStrengthOptions),
|
||||
];
|
||||
},
|
||||
command: (values) => `调节主驾座椅:通风${optionLabel(levelOptions, values.ventilation)},加热${optionLabel(levelOptions, values.heating)},按摩模式${optionLabel(massageModeOptions, values.massage_mode)},按摩强度${optionLabel(massageStrengthOptions, values.massage_strength)}`,
|
||||
commands: (values) => {
|
||||
const commands = [];
|
||||
if (hasControlValue(values, "ventilation")) commands.push(`seat driver vent ${values.ventilation}`);
|
||||
if (hasControlValue(values, "heating")) commands.push(`seat driver heat ${values.heating}`);
|
||||
if (hasControlValue(values, "massage_mode")) {
|
||||
commands.push(values.massage_mode === "off"
|
||||
? "seat driver massage off"
|
||||
: `seat driver massage mode ${values.massage_mode}`);
|
||||
}
|
||||
if (hasControlValue(values, "massage_strength")) {
|
||||
commands.push(values.massage_strength === "off"
|
||||
? "seat driver massage off"
|
||||
: `seat driver massage strength ${values.massage_strength}`);
|
||||
}
|
||||
return [...new Set(commands)];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "windows", name: "车窗", icon: "gallery-vertical-end",
|
||||
@@ -680,19 +803,24 @@
|
||||
rangeControl("rear_left", "左后窗", readText("#rearLeftWindow")),
|
||||
rangeControl("rear_right", "右后窗", readText("#rearRightWindow")),
|
||||
],
|
||||
command: (values) => `调节车窗开合比例:左前窗${values.front_left}%,右前窗${values.front_right}%,左后窗${values.rear_left}%,右后窗${values.rear_right}%`,
|
||||
commands: (values) => Object.entries({
|
||||
front_left: "front-left", front_right: "front-right", rear_left: "rear-left", rear_right: "rear-right",
|
||||
}).filter(([key]) => hasControlValue(values, key))
|
||||
.map(([key, target]) => `window ${target} position ${Math.round(Number(values[key]))}`),
|
||||
},
|
||||
{
|
||||
id: "shade", name: "遮阳帘", icon: "panels-top-left",
|
||||
rows: () => [["当前位置", readText("#shadePositionValue")]],
|
||||
controls: () => [rangeControl("position", "当前位置", readText("#shadePositionValue"), 0, 100, 1, "%", "0% 关闭 · 100% 打开")],
|
||||
command: (values) => `将遮阳帘开合位置调整为${values.position}%`,
|
||||
commands: (values) => hasControlValue(values, "position")
|
||||
? [`shade position ${Math.round(Number(values.position))}`]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
id: "reading", name: "阅读灯", icon: "book-open",
|
||||
rows: () => [["开关状态", readText("#interiorLightPower", "等待反馈")]],
|
||||
controls: () => [selectControl("power", "开关状态", /开启|ON/.test(readText("#interiorLightPower")) ? "on" : "off", toggleOptions)],
|
||||
command: (values) => `${values.power === "on" ? "打开" : "关闭"}阅读灯`,
|
||||
commands: (values) => hasControlValue(values, "power") ? [`light reading all ${values.power}`] : [],
|
||||
},
|
||||
{
|
||||
id: "ambient", name: "氛围灯", icon: "sparkles",
|
||||
@@ -706,31 +834,39 @@
|
||||
const [frontColor, rearColor] = splitReadout("#ambientColorIndex");
|
||||
return [
|
||||
selectControl("power", "开关状态", /OFF|关闭/.test(readText("#ambientPower")) ? "off" : "on", toggleOptions),
|
||||
rangeControl("front_brightness", "前排亮度", frontBrightness, 0, 5, 1, " 级"),
|
||||
selectControl("front_color", "前排颜色", String(clampControlNumber(frontColor, 0, 15, 0)), colorIndexOptions),
|
||||
rangeControl("rear_brightness", "后排亮度", rearBrightness, 0, 5, 1, " 级"),
|
||||
selectControl("rear_color", "后排颜色", String(clampControlNumber(rearColor, 0, 15, 0)), colorIndexOptions),
|
||||
rangeControl("brightness", "全车亮度", frontBrightness === "-" ? rearBrightness : frontBrightness, 1, 5, 1, " 级"),
|
||||
selectControl("color", "全车颜色", String(clampControlNumber(frontColor === "-" ? rearColor : frontColor, 1, 127, 1)), colorIndexOptions),
|
||||
];
|
||||
},
|
||||
command: (values) => `调节氛围灯:${values.power === "on" ? "开启" : "关闭"},前排亮度${values.front_brightness}级、颜色${optionLabel(colorIndexOptions, values.front_color)},后排亮度${values.rear_brightness}级、颜色${optionLabel(colorIndexOptions, values.rear_color)}`,
|
||||
commands: (values) => [
|
||||
...(hasControlValue(values, "power") ? [`light ambient ${values.power}`] : []),
|
||||
...(hasControlValue(values, "brightness") ? [`light ambient brightness ${Math.round(Number(values.brightness))}`] : []),
|
||||
...(hasControlValue(values, "color") ? [`light ambient color ${Math.round(Number(values.color))}`] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "headlight", name: "前大灯", icon: "car-front",
|
||||
controllable: false,
|
||||
rows: () => [["近光灯", readText("#lowBeamState")], ["远光灯", readText("#highBeamState")], ["雾灯", readText("#fogLightState")], ["转向灯", readText("#turnSignalState")]],
|
||||
controls: () => [["low_beam", "近光灯", "#lowBeamState"], ["high_beam", "远光灯", "#highBeamState"], ["fog_light", "雾灯", "#fogLightState"], ["turn_signal", "转向灯", "#turnSignalState"]].map(([key, label, selector]) => selectControl(key, label, /开启|ON/.test(readText(selector)) ? "on" : "off", toggleOptions)),
|
||||
command: (values) => `调节前大灯:近光灯${optionLabel(toggleOptions, values.low_beam)},远光灯${optionLabel(toggleOptions, values.high_beam)},雾灯${optionLabel(toggleOptions, values.fog_light)},转向灯${optionLabel(toggleOptions, values.turn_signal)}`,
|
||||
},
|
||||
{
|
||||
id: "climate", name: "空调", icon: "fan",
|
||||
rows: () => [["开关状态", readText("#climatePower", "等待反馈")], ["主驾温度", readText("#driverTemperature")], ["副驾温度", readText("#passengerTemperature")], ["风量", readText("#fanLevel")], ["循环模式", readText("#cycleMode")]],
|
||||
controls: () => [
|
||||
selectControl("power", "开关状态", /ON|ACTIVE|开启/.test(readText("#climatePower")) ? "on" : "off", toggleOptions),
|
||||
rangeControl("driver_temperature", "主驾温度", readText("#driverTemperature"), 16, 32, 0.5, "℃"),
|
||||
rangeControl("passenger_temperature", "副驾温度", readText("#passengerTemperature"), 16, 32, 0.5, "℃"),
|
||||
rangeControl("fan_level", "风量", readText("#fanLevel"), 0, 7, 1, " 档"),
|
||||
rangeControl("driver_temperature", "主驾温度", Math.round(clampControlNumber(readText("#driverTemperature"), 18, 32, 24)), 18, 32, 1, "℃"),
|
||||
rangeControl("passenger_temperature", "副驾温度", Math.round(clampControlNumber(readText("#passengerTemperature"), 18, 32, 24)), 18, 32, 1, "℃"),
|
||||
rangeControl("fan_level", "风量", readText("#fanLevel"), 1, 7, 1, " 档"),
|
||||
selectControl("cycle_mode", "循环模式", readText("#cycleMode").includes("内") ? "internal" : "external", [["internal", "内循环"], ["external", "外循环"]]),
|
||||
],
|
||||
command: (values) => `调节空调:${values.power === "on" ? "开启" : "关闭"},主驾${values.driver_temperature}℃,副驾${values.passenger_temperature}℃,风量${values.fan_level}档,${values.cycle_mode === "internal" ? "内循环" : "外循环"}`,
|
||||
commands: (values) => [
|
||||
...(hasControlValue(values, "power") ? [`ac ${values.power}`] : []),
|
||||
...(hasControlValue(values, "driver_temperature") ? [`ac temp driver ${Math.round(Number(values.driver_temperature))}`] : []),
|
||||
...(hasControlValue(values, "passenger_temperature") ? [`ac temp passenger ${Math.round(Number(values.passenger_temperature))}`] : []),
|
||||
...(hasControlValue(values, "fan_level") ? [`ac fan ${Math.round(Number(values.fan_level))}`] : []),
|
||||
...(hasControlValue(values, "cycle_mode") ? [`ac cycle ${values.cycle_mode}`] : []),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -753,7 +889,8 @@
|
||||
grid.innerHTML = syncedDevices.map((device) => {
|
||||
const rows = device.rows();
|
||||
const waiting = rows.some(([, value]) => /--|等待|同步中|未知/.test(value));
|
||||
const controlOpen = displaySync.controlDevice === device.id;
|
||||
const controllable = device.controllable !== false;
|
||||
const controlOpen = controllable && displaySync.controlDevice === device.id;
|
||||
const hasDraft = Boolean(displaySync.deviceDrafts[device.id]);
|
||||
const pending = Boolean(displaySync.pendingControls[device.id]);
|
||||
const stateLabel = pending ? "已发送 · 等待同步" : hasDraft ? "参数待发送" : `${rows.length} 项关键状态`;
|
||||
@@ -762,13 +899,13 @@
|
||||
<button class="v2-device-card-focus" type="button" data-device-focus="${device.id}" aria-label="在孪生界面查看${device.name}">
|
||||
<span><i data-lucide="${device.icon}"></i></span><span><strong>${device.name}</strong><small data-device-card-state>${stateLabel}</small></span><i class="v2-device-signal"></i>
|
||||
</button>
|
||||
<button class="v2-device-adjust-toggle" type="button" data-device-control-toggle="${device.id}" aria-expanded="${String(controlOpen)}"><i data-lucide="sliders-horizontal"></i><span>${controlOpen ? "收起" : "调节"}</span></button>
|
||||
<button class="v2-device-adjust-toggle" type="button" data-device-control-toggle="${device.id}" aria-expanded="${String(controlOpen)}"${controllable ? "" : " disabled"}><i data-lucide="${controllable ? "sliders-horizontal" : "eye"}"></i><span>${controllable ? (controlOpen ? "收起" : "调节") : "只读"}</span></button>
|
||||
</header>
|
||||
<dl>${rows.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}</dl>
|
||||
<div class="v2-device-controls" data-device-controls="${device.id}"${controlOpen ? "" : " hidden"}>
|
||||
<div class="v2-device-controls-intro"><span>手动调节</span><small>参数确认后通过编排链路发送至车机</small></div>
|
||||
${device.controls().map((control) => renderDeviceControl(device, control)).join("")}
|
||||
<button class="v2-device-control-apply" type="button" data-device-control-apply="${device.id}"${pending ? " disabled" : ""}><i data-lucide="send"></i><span>${pending ? "等待车机同步" : "发送控制"}</span></button>
|
||||
<button class="v2-device-control-apply" type="button" data-device-control-apply="${device.id}"${pending || !hasDraft ? " disabled" : ""}><i data-lucide="send"></i><span>${pending ? "等待车机同步" : hasDraft ? "发送 CAN 控制" : "请先修改参数"}</span></button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join("");
|
||||
@@ -799,28 +936,68 @@
|
||||
}));
|
||||
}
|
||||
|
||||
function sendDeviceControl(deviceId) {
|
||||
async function sendDeviceControl(deviceId) {
|
||||
const device = syncedDevices.find((item) => item.id === deviceId);
|
||||
if (!device) return;
|
||||
const values = Object.fromEntries(device.controls().map((control) => [control.key, String(deviceControlValue(device.id, control))]));
|
||||
const utterance = device.command(values);
|
||||
if (device.controllable === false || typeof device.commands !== "function") {
|
||||
window.CognitiveAgentConsole?.notify?.(`${device.name}当前只有状态反馈,尚无已验证的 CAN 控制白名单`);
|
||||
return;
|
||||
}
|
||||
const draft = displaySync.deviceDrafts[device.id];
|
||||
if (!draft || !Object.keys(draft).length) {
|
||||
window.CognitiveAgentConsole?.notify?.("请先修改需要发送的参数");
|
||||
return;
|
||||
}
|
||||
if (displaySync.pendingControls[device.id]) {
|
||||
window.CognitiveAgentConsole?.notify?.("该设备的上一组 CAN 指令仍在执行");
|
||||
return;
|
||||
}
|
||||
const values = Object.fromEntries(Object.entries(draft).map(([key, value]) => [key, String(value)]));
|
||||
const commands = device.commands(values).filter(Boolean);
|
||||
if (!commands.length) {
|
||||
window.CognitiveAgentConsole?.notify?.("修改项没有对应的 CAN 白名单控制指令");
|
||||
return;
|
||||
}
|
||||
const commandSummary = commands.join(";");
|
||||
const detail = {
|
||||
device_id: device.id,
|
||||
device_name: device.name,
|
||||
controls: values,
|
||||
utterance,
|
||||
can_commands: commands,
|
||||
source: "web_console",
|
||||
requested_at: new Date().toISOString(),
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-control-request", { detail }));
|
||||
const accepted = window.CognitiveAgentConsole?.executeUtterance?.(utterance, { autoSubmit: true }) || false;
|
||||
if (!accepted) {
|
||||
window.CognitiveAgentConsole?.notify?.("上一条指令仍在执行,请稍候");
|
||||
const execute = window.CognitiveAgentConsole?.executeCanCommands;
|
||||
if (typeof execute !== "function") {
|
||||
window.CognitiveAgentConsole?.notify?.("当前页面尚未加载 CAN 直控接口");
|
||||
return;
|
||||
}
|
||||
displaySync.pendingControls[device.id] = utterance;
|
||||
appendSyncLog({ category: "device", title: `网页端调节${device.name}`, detail: utterance, status: "info" });
|
||||
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-can-control-request", { detail }));
|
||||
displaySync.pendingControls[device.id] = commandSummary;
|
||||
appendSyncLog({ category: "device", title: `网页端直控${device.name}`, detail: commandSummary, status: "info" });
|
||||
renderDirectCanCommands(commands, device.name);
|
||||
renderSyncedDevices();
|
||||
try {
|
||||
await execute(commands, { deviceName: device.name });
|
||||
delete displaySync.deviceDrafts[device.id];
|
||||
appendSyncLog({
|
||||
category: "device",
|
||||
title: `${device.name} CAN 控制已执行`,
|
||||
detail: commandSummary,
|
||||
status: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
appendSyncLog({
|
||||
category: "device",
|
||||
title: `${device.name} CAN 控制发送失败`,
|
||||
detail: error?.message || "CAN 控制请求失败",
|
||||
status: "error",
|
||||
});
|
||||
window.CognitiveAgentConsole?.notify?.(error?.message || "CAN 控制请求失败");
|
||||
} finally {
|
||||
delete displaySync.pendingControls[device.id];
|
||||
renderSyncedDevices();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSyncedDeviceRender() {
|
||||
@@ -1045,6 +1222,12 @@
|
||||
if ($("#v2TaskKind")) $("#v2TaskKind").textContent = kind;
|
||||
if ($("#v2TaskCommand")) $("#v2TaskCommand").textContent = utterance || "等待车机端或网页端选择预设模式、输入一句话指令";
|
||||
const normalizedStatus = String(task.status || "idle").toLowerCase();
|
||||
const verificationStatus = String(
|
||||
result?.execution_outcome
|
||||
|| result?.execution_report?.spec?.verification_status
|
||||
|| "",
|
||||
);
|
||||
const submittedUnverified = verificationStatus === "submitted_unverified";
|
||||
const statusMap = {
|
||||
idle: ["等待任务", "idle"],
|
||||
running: ["构建 / 执行中", "running"],
|
||||
@@ -1053,7 +1236,9 @@
|
||||
needs_confirmation: ["等待确认", "partial"],
|
||||
failed: ["执行失败", "failed"],
|
||||
};
|
||||
const [label, state] = statusMap[normalizedStatus] || statusMap.idle;
|
||||
const [label, state] = submittedUnverified && normalizedStatus === "succeeded"
|
||||
? ["指令已提交 · 等待状态确认", "partial"]
|
||||
: statusMap[normalizedStatus] || statusMap.idle;
|
||||
const badge = $("#v2TaskState");
|
||||
if (badge) {
|
||||
badge.textContent = label;
|
||||
@@ -1068,9 +1253,23 @@
|
||||
});
|
||||
renderTaskTimeline(task);
|
||||
if (normalizedStatus === "running") {
|
||||
renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" });
|
||||
// The streaming endpoint can publish fully validated commands before the
|
||||
// slower display-sync poll observes the terminal execution state. A stale
|
||||
// `running` snapshot for the same task must not erase those commands.
|
||||
if (!validatedCommandsBelongToTask(task)) {
|
||||
resetValidatedCommandContext();
|
||||
renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" });
|
||||
}
|
||||
} else if (result) {
|
||||
renderParsedCanCommands(result);
|
||||
const commands = parsedCanCommands(result);
|
||||
if (commands.length) {
|
||||
validatedCommandsVisible = true;
|
||||
rememberValidatedCommandContext(result);
|
||||
renderParsedCanCommands(result);
|
||||
} else if (!validatedCommandsBelongToTask(task)) {
|
||||
resetValidatedCommandContext();
|
||||
renderParsedCanCommands(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1106,12 +1305,14 @@
|
||||
setTaskPresentation(task);
|
||||
const sourceLabel = task.source === "web_console" ? "网页端" : "车机端";
|
||||
if (task.utterance) {
|
||||
const submittedUnverified = task.result?.execution_outcome === "submitted_unverified"
|
||||
|| task.result?.execution_report?.spec?.verification_status === "submitted_unverified";
|
||||
appendSyncLog({
|
||||
key: `task:${revision}:${task.status}`,
|
||||
category: "task",
|
||||
title: `${sourceLabel}${task.status === "running" ? "下发编排任务" : "任务状态已同步"}`,
|
||||
detail: `${task.utterance} · ${task.status || "idle"}`,
|
||||
status: ["succeeded", "partially_succeeded"].includes(task.status) ? "success" : task.status === "failed" ? "error" : "info",
|
||||
detail: `${task.utterance} · ${submittedUnverified ? "指令已提交,等待车辆状态确认" : task.status || "idle"}`,
|
||||
status: submittedUnverified ? "info" : ["succeeded", "partially_succeeded"].includes(task.status) ? "success" : task.status === "failed" ? "error" : "info",
|
||||
});
|
||||
}
|
||||
const result = task.result;
|
||||
@@ -1348,36 +1549,64 @@
|
||||
});
|
||||
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
|
||||
$("#requestForm")?.addEventListener("submit", () => {
|
||||
resetValidatedCommandContext();
|
||||
renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" });
|
||||
});
|
||||
window.addEventListener("cognitive-agent:validated-can-commands", (event) => {
|
||||
const result = event.detail || {};
|
||||
validatedCommandsVisible = parsedCanCommands(result).length > 0;
|
||||
if (validatedCommandsVisible) rememberValidatedCommandContext(result);
|
||||
else resetValidatedCommandContext();
|
||||
renderParsedCanCommands(result, {
|
||||
message: "已完成白名单校验,正在下发控制指令…",
|
||||
});
|
||||
});
|
||||
window.addEventListener("cognitive-agent:result", (event) => {
|
||||
const result = event.detail || {};
|
||||
renderParsedCanCommands(result);
|
||||
const submittedUnverified = result.execution_outcome === "submitted_unverified"
|
||||
|| result.execution_report?.spec?.verification_status === "submitted_unverified";
|
||||
const commands = parsedCanCommands(result);
|
||||
if (commands.length) {
|
||||
validatedCommandsVisible = true;
|
||||
rememberValidatedCommandContext(result);
|
||||
renderParsedCanCommands(result);
|
||||
} else if (!validatedCommandsBelongToTask({
|
||||
utterance: result.utterance,
|
||||
result,
|
||||
})) {
|
||||
resetValidatedCommandContext();
|
||||
renderParsedCanCommands(result);
|
||||
}
|
||||
appendSyncLog({
|
||||
key: result.request_id ? `result:${result.request_id}` : "",
|
||||
category: "task",
|
||||
title: ["succeeded", "partially_succeeded"].includes(result.status) ? "编排任务执行完成" : "编排任务返回结果",
|
||||
detail: `${result.summary || result.message || "执行反馈已同步"} · ${result.status || "unknown"}`,
|
||||
status: ["succeeded", "partially_succeeded"].includes(result.status) ? "success" : result.status === "failed" ? "error" : "info",
|
||||
title: submittedUnverified
|
||||
? "CAN 指令已提交,等待车辆状态确认"
|
||||
: ["succeeded", "partially_succeeded"].includes(result.status)
|
||||
? "编排任务执行完成"
|
||||
: "编排任务返回结果",
|
||||
detail: `${result.summary || result.message || "执行反馈已同步"} · ${submittedUnverified ? "submitted_unverified" : result.status || "unknown"}`,
|
||||
status: submittedUnverified ? "info" : ["succeeded", "partially_succeeded"].includes(result.status) ? "success" : result.status === "failed" ? "error" : "info",
|
||||
});
|
||||
});
|
||||
window.addEventListener("cognitive-agent:request-complete", (event) => {
|
||||
const pendingDevice = Object.entries(displaySync.pendingControls).find(([, utterance]) => utterance === event.detail?.utterance)?.[0];
|
||||
if (pendingDevice) {
|
||||
const device = syncedDevices.find((item) => item.id === pendingDevice);
|
||||
delete displaySync.pendingControls[pendingDevice];
|
||||
if (!event.detail?.error) delete displaySync.deviceDrafts[pendingDevice];
|
||||
appendSyncLog({
|
||||
category: "device",
|
||||
title: event.detail?.error ? `${device?.name || "设备"}控制发送失败` : `${device?.name || "设备"}控制已执行`,
|
||||
detail: event.detail?.error?.message || "控制结果已进入车机状态同步链路",
|
||||
status: event.detail?.error ? "error" : "success",
|
||||
});
|
||||
renderSyncedDevices();
|
||||
if (event.detail?.error && !validatedCommandsVisible) {
|
||||
renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" });
|
||||
}
|
||||
if (event.detail?.error) {
|
||||
renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" });
|
||||
appendSyncLog({ category: "task", title: "编排任务执行失败", detail: event.detail.error.message || "请求失败", status: "error" });
|
||||
const error = event.detail.error;
|
||||
const outcomeLabels = {
|
||||
failed_or_blocked: "失败或已阻止",
|
||||
submitted_unverified: "已提交但未验证",
|
||||
verified_success: "反馈验证成功",
|
||||
};
|
||||
const outcome = outcomeLabels[error.outcome];
|
||||
appendSyncLog({
|
||||
category: "task",
|
||||
title: "编排任务执行失败",
|
||||
detail: `${error.detail || error.message || "请求失败"}${outcome ? ` · ${outcome}` : ""}`,
|
||||
status: "error",
|
||||
});
|
||||
}
|
||||
});
|
||||
window.addEventListener("cognitive-agent:connection", (event) => {
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
<link rel="icon" href="/icons/app-icon-192.png" />
|
||||
<title>511工程-智能座舱</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=dual-seat-16" />
|
||||
<link rel="stylesheet" href="/console-v2.css?v=sync-layout-40" />
|
||||
<link rel="stylesheet" href="/console-v2.css?v=sync-layout-41" />
|
||||
<link rel="stylesheet" href="/self-extension.css?v=sync-layout-26" />
|
||||
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
|
||||
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
|
||||
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
||||
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
||||
<script src="/network.js?v=android-pad-2" defer></script>
|
||||
<script src="/app.js?v=dual-seat-101" defer></script>
|
||||
<script src="/app.js?v=dual-seat-107" defer></script>
|
||||
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
|
||||
<script src="/pwa.js?v=android-pad-3" defer></script>
|
||||
<script src="/console-v2.js?v=sync-layout-41" defer></script>
|
||||
<script src="/console-v2.js?v=task-outcome-47" defer></script>
|
||||
<script src="/self-extension.js?v=sync-layout-26" defer></script>
|
||||
</head>
|
||||
<body class="console-v2">
|
||||
@@ -111,7 +111,7 @@
|
||||
<button type="button" class="mode-option" data-execution-mode="dry_run">
|
||||
<i data-lucide="flask-conical"></i>虚拟 CAN
|
||||
</button>
|
||||
<button type="button" class="mode-option active" id="hardwareMode" data-execution-mode="hardware" disabled>
|
||||
<button type="button" class="mode-option active" id="hardwareMode" data-execution-mode="hardware">
|
||||
<i data-lucide="radio-tower"></i>真实台架
|
||||
</button>
|
||||
<button type="button" class="mode-option" id="simulationMode" data-execution-mode="simulated_hardware" disabled>
|
||||
|
||||
@@ -718,7 +718,7 @@
|
||||
}
|
||||
|
||||
function isPhysicalVehicleSource(value) {
|
||||
return ["physical_can", "physical_can_ch0", "physical_can_ch1", "physical_partial"].includes(value);
|
||||
return ["physical_can", "physical_can_ch0", "physical_can_ch1", "wireless_mqtt_ch0", "wireless_mqtt_ch1", "physical_partial"].includes(value);
|
||||
}
|
||||
|
||||
function containsAuthoritativePhysicalCh1State(value) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v202";
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v210";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||
const APP_SHELL = [
|
||||
@@ -6,11 +6,11 @@ const APP_SHELL = [
|
||||
"/index.html",
|
||||
"/styles.css?v=dual-seat-16",
|
||||
"/network.js?v=android-pad-2",
|
||||
"/app.js?v=dual-seat-101",
|
||||
"/app.js?v=dual-seat-107",
|
||||
"/gesture-control.js?v=android-pad-9",
|
||||
"/pwa.js?v=android-pad-3",
|
||||
"/console-v2.css?v=sync-layout-40",
|
||||
"/console-v2.js?v=sync-layout-41",
|
||||
"/console-v2.css?v=sync-layout-41",
|
||||
"/console-v2.js?v=task-outcome-47",
|
||||
"/self-extension.css?v=sync-layout-26",
|
||||
"/self-extension.js?v=sync-layout-26",
|
||||
"/assets/pets/self-extension-cat.png?v=3",
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
{
|
||||
"schema": "cognitive-agent-can-semantic-protocol/v1",
|
||||
"version": "1.0.0",
|
||||
"description": "Single source of truth for CAN0/CAN1 semantic commands and transport metadata. Frame encoders and feedback algorithms remain named special handlers when they require stateful closed-loop behavior.",
|
||||
"channels": {
|
||||
"0": {
|
||||
"name": "passenger_seat_can",
|
||||
"frame_format": "classic_can",
|
||||
"bitrate": 500000
|
||||
},
|
||||
"1": {
|
||||
"name": "vehicle_body_can",
|
||||
"frame_format": "mixed",
|
||||
"arbitration_bitrate": 500000,
|
||||
"data_bitrate": 2000000
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "can0.seat.position",
|
||||
"channel": 0,
|
||||
"domain": "seat",
|
||||
"capabilities": [
|
||||
"seat.set_longitudinal_position",
|
||||
"seat.set_cushion_tilt",
|
||||
"seat.set_backrest_recline",
|
||||
"seat.set_legrest_height"
|
||||
],
|
||||
"help": "seat passenger <horizontal|basin|backrest|legrest> position <0-100>",
|
||||
"patterns": [
|
||||
[
|
||||
{"literal": "seat"},
|
||||
{"literal": "passenger"},
|
||||
{"enum": "axis", "values": ["horizontal", "longitudinal", "basin", "height", "backrest", "legrest"]},
|
||||
{"literal": "position"},
|
||||
{"integer": "position_pct", "minimum": 0, "maximum": 100}
|
||||
]
|
||||
],
|
||||
"frames": [
|
||||
{
|
||||
"key": "move",
|
||||
"frame_id": "0x300",
|
||||
"format": "classic_can",
|
||||
"brs": false,
|
||||
"dlc": 8,
|
||||
"template_kind": "can0_motion_fields",
|
||||
"base_payload_hex": "00 10 00 00 00 00 00 00",
|
||||
"fields": {
|
||||
"opcode": {"start_bit": 0, "bit_length": 8, "absolute": 1, "home_zero": 11},
|
||||
"motor": {"start_bit": 8, "bit_length": 2, "values": {"horizontal": 0, "longitudinal": 0, "basin": 1, "height": 1, "backrest": 2, "legrest": 3}},
|
||||
"protocol_version": {"start_bit": 12, "bit_length": 2, "constant": 1},
|
||||
"sequence": {"start_bit": 16, "bit_length": 16, "runtime_allocated": true},
|
||||
"param_a": {"start_bit": 32, "bit_length": 16, "scale": 100, "home_key": 19282},
|
||||
"param_b": {"start_bit": 48, "bit_length": 16, "absolute_timeout_ms": 30000, "home_timeout_s": 120}
|
||||
},
|
||||
"repeat": {"kind": "fixed", "count": 1, "period_ms": 0},
|
||||
"feedback_ids": ["0x301", "0x302"],
|
||||
"feedback_policy": "ack_then_motor_terminal"
|
||||
}
|
||||
],
|
||||
"special_handler": "can0_absolute_position",
|
||||
"evidence": "passenger_firmware_hil_verified"
|
||||
},
|
||||
{
|
||||
"id": "can0.seat.relative",
|
||||
"channel": 0,
|
||||
"domain": "seat",
|
||||
"capabilities": [
|
||||
"seat.set_longitudinal_position",
|
||||
"seat.set_cushion_tilt",
|
||||
"seat.set_backrest_recline",
|
||||
"seat.set_legrest_height"
|
||||
],
|
||||
"help": "seat passenger <axis> relative <direction> <1-100>",
|
||||
"patterns": [
|
||||
[{"literal": "seat"}, {"literal": "passenger"}, {"enum": "axis", "values": ["horizontal", "longitudinal"]}, {"literal": "relative"}, {"enum": "direction", "values": ["forward", "backward"]}, {"integer": "delta_pct", "minimum": 1, "maximum": 100}],
|
||||
[{"literal": "seat"}, {"literal": "passenger"}, {"enum": "axis", "values": ["basin", "height"]}, {"literal": "relative"}, {"enum": "direction", "values": ["up", "down"]}, {"integer": "delta_pct", "minimum": 1, "maximum": 100}],
|
||||
[{"literal": "seat"}, {"literal": "passenger"}, {"literal": "backrest"}, {"literal": "relative"}, {"enum": "direction", "values": ["forward", "backward"]}, {"integer": "delta_pct", "minimum": 1, "maximum": 100}],
|
||||
[{"literal": "seat"}, {"literal": "passenger"}, {"literal": "legrest"}, {"literal": "relative"}, {"enum": "direction", "values": ["up", "down"]}, {"integer": "delta_pct", "minimum": 1, "maximum": 100}]
|
||||
],
|
||||
"frames": [
|
||||
{
|
||||
"key": "move",
|
||||
"frame_id": "0x300",
|
||||
"format": "classic_can",
|
||||
"brs": false,
|
||||
"dlc": 8,
|
||||
"template_kind": "can0_relative_closed_loop_or_timed_fallback",
|
||||
"base_payload_hex": "03 10 00 00 00 00 00 00",
|
||||
"fields": {
|
||||
"closed_loop_opcode": 1,
|
||||
"timed_fallback_opcode": 3,
|
||||
"timed_fallback_max_delta_pct": 15,
|
||||
"direction_values": {"increase": 1, "decrease": 2}
|
||||
},
|
||||
"repeat": {"kind": "fixed", "count": 1, "period_ms": 0},
|
||||
"feedback_ids": ["0x301", "0x302"],
|
||||
"feedback_policy": "fresh_position_then_ack_then_motor_terminal"
|
||||
}
|
||||
],
|
||||
"special_handler": "can0_relative_position",
|
||||
"evidence": "passenger_firmware_hil_verified"
|
||||
},
|
||||
{
|
||||
"id": "can0.seat.thermal",
|
||||
"channel": 0,
|
||||
"domain": "seat",
|
||||
"capabilities": ["seat.set_ventilation", "seat.set_heating"],
|
||||
"help": "seat passenger <vent|heat> <off|low|medium|high>",
|
||||
"patterns": [
|
||||
[{"literal": "seat"}, {"literal": "passenger"}, {"enum": "feature", "values": ["vent", "heat"]}, {"enum": "level", "values": ["off", "low", "medium", "high"]}]
|
||||
],
|
||||
"frames": [
|
||||
{
|
||||
"key": "thermal_mode",
|
||||
"frame_id": "0x3CE",
|
||||
"format": "classic_can",
|
||||
"brs": false,
|
||||
"dlc": 8,
|
||||
"template_kind": "can0_heating_mode_optional",
|
||||
"base_payload_hex": "10 00 00 00 00 00 00 00",
|
||||
"repeat": {"kind": "fixed", "count": 1, "period_ms": 0},
|
||||
"feedback_ids": ["0x4FA"],
|
||||
"optional_when": "heating_on"
|
||||
},
|
||||
{
|
||||
"key": "thermal_control",
|
||||
"frame_id": "0x29E",
|
||||
"format": "classic_can",
|
||||
"brs": false,
|
||||
"dlc": 8,
|
||||
"template_kind": "can0_thermal_bit_fields",
|
||||
"base_payload_hex": "00 00 00 00 00 00 00 00",
|
||||
"repeat": {"kind": "fixed", "count": 1, "period_ms": 0},
|
||||
"feedback_ids": ["0x4FA"],
|
||||
"feedback_policy": "thermal_state_match"
|
||||
}
|
||||
],
|
||||
"special_handler": "can0_thermal_preserve_peer_feature",
|
||||
"evidence": "passenger_firmware_hil_verified"
|
||||
},
|
||||
{
|
||||
"id": "can0.seat.dynamic",
|
||||
"channel": 0,
|
||||
"domain": "seat",
|
||||
"capabilities": ["seat.start_dynamic_mode", "seat.stop_dynamic_mode"],
|
||||
"help": "seat passenger dynamic <rocking-horse|cradle|stop>",
|
||||
"patterns": [
|
||||
[{"literal": "seat"}, {"literal": "passenger"}, {"literal": "dynamic"}, {"enum": "mode", "values": ["rocking-horse", "cradle", "stop"]}],
|
||||
[{"literal": "seat"}, {"literal": "passenger"}, {"literal": "dynamic"}, {"literal": "start"}, {"enum": "mode", "values": ["rocking-horse", "cradle"]}]
|
||||
],
|
||||
"frames": [
|
||||
{
|
||||
"key": "dynamic",
|
||||
"frame_id": "0x300",
|
||||
"format": "classic_can",
|
||||
"brs": false,
|
||||
"dlc": 8,
|
||||
"template_kind": "can0_dynamic_fields",
|
||||
"base_payload_hex": "0E 10 00 00 00 00 00 00",
|
||||
"fields": {"start_opcode": 14, "stop_opcode": 15, "mode_values": {"rocking-horse": 1, "cradle": 2, "stop": 0}},
|
||||
"repeat": {"kind": "fixed", "count": 1, "period_ms": 0},
|
||||
"feedback_ids": ["0x301", "0x306"],
|
||||
"feedback_policy": "ack_then_dynamic_state"
|
||||
}
|
||||
],
|
||||
"special_handler": "can0_dynamic_mode",
|
||||
"evidence": "passenger_firmware_hil_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.door.rear_lock",
|
||||
"channel": 1,
|
||||
"domain": "door",
|
||||
"capabilities": ["door.set_rear_lock"],
|
||||
"help": "door rear <lock|unlock>",
|
||||
"patterns": [[{"literal": "door"}, {"literal": "rear"}, {"enum": "state", "values": ["lock", "unlock"]}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x32B", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "0A 00 00 00 00 00 00 00", "fields": {"state": {"start_bit": 14, "bit_length": 2, "values": {"lock": 1, "unlock": 2}}}, "repeat": {"kind": "fixed", "count": 1, "period_ms": 0}, "feedback_ids": ["0x407"], "feedback_policy": "rear_lock_state_match"}],
|
||||
"evidence": "matrix_candidate_with_feedback"
|
||||
},
|
||||
{
|
||||
"id": "can1.window.motion",
|
||||
"channel": 1,
|
||||
"domain": "window",
|
||||
"capabilities": ["window.set_motion"],
|
||||
"help": "window <all|front|rear|front-left|front-right|rear-left|rear-right> <open|close|stop>",
|
||||
"patterns": [[{"literal": "window"}, {"enum": "target", "values": ["all", "front", "rear", "front-left", "front-right", "rear-left", "rear-right"]}, {"enum": "motion", "values": ["open", "close", "stop"]}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x431", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "window_motion_bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"starts": [48, 56, 51, 59], "motion_values": {"open": 1, "close": 2, "stop": 3}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x388"], "feedback_policy": "window_state_match"}],
|
||||
"evidence": "vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.window.position",
|
||||
"channel": 1,
|
||||
"domain": "window",
|
||||
"capabilities": ["window.set_position"],
|
||||
"help": "window <front-left|front-right|rear-left|rear-right> position <0-100>",
|
||||
"patterns": [[{"literal": "window"}, {"enum": "target", "values": ["front-left", "front-right", "rear-left", "rear-right"]}, {"literal": "position"}, {"integer": "position_pct", "minimum": 0, "maximum": 100}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x431", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "window_feedback_closed_loop", "base_payload_hex": "00 00 00 00 00 00 00 00", "repeat": {"kind": "special", "count": 1, "period_ms": 0}, "feedback_ids": ["0x388"], "feedback_policy": "fresh_position_then_motion_then_stop"}],
|
||||
"special_handler": "window_position"
|
||||
},
|
||||
{
|
||||
"id": "can1.climate.power",
|
||||
"channel": 1,
|
||||
"domain": "climate",
|
||||
"capabilities": ["climate.set_power", "climate.set_ac"],
|
||||
"help": "ac <on|off>",
|
||||
"patterns": [[{"literal": "ac"}, {"enum": "state", "values": ["on", "off"]}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"state": {"start_bit": 28, "bit_length": 2, "values": {"on": 1, "off": 2}}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_power_match"}],
|
||||
"evidence": "matrix_and_vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.climate.fan",
|
||||
"channel": 1,
|
||||
"domain": "climate",
|
||||
"capabilities": ["climate.set_fan_level"],
|
||||
"help": "ac fan <1-7>",
|
||||
"patterns": [[{"literal": "ac"}, {"literal": "fan"}, {"integer": "level", "minimum": 1, "maximum": 7}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"level": {"start_bit": 4, "bit_length": 4}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_fan_match"}],
|
||||
"evidence": "matrix_and_vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.climate.mode",
|
||||
"channel": 1,
|
||||
"domain": "climate",
|
||||
"capabilities": ["climate.set_outlet_mode"],
|
||||
"help": "ac mode <face|face-feet|feet|feet-defrost|defrost|face-feet-defrost|face-defrost>",
|
||||
"patterns": [[{"literal": "ac"}, {"literal": "mode"}, {"enum": "mode", "values": ["face", "face-feet", "feet", "feet-defrost", "defrost", "face-feet-defrost", "face-defrost"]}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"mode": {"start_bit": 0, "bit_length": 4, "values": {"face": 1, "face-feet": 2, "feet": 3, "feet-defrost": 4, "defrost": 5, "face-feet-defrost": 6, "face-defrost": 7}}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_mode_match"}],
|
||||
"evidence": "matrix_and_vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.climate.temperature",
|
||||
"channel": 1,
|
||||
"domain": "climate",
|
||||
"capabilities": ["climate.set_temperature"],
|
||||
"help": "ac temp <driver|passenger|rear> <18-32>",
|
||||
"patterns": [[{"literal": "ac"}, {"literal": "temp"}, {"enum": "target", "values": ["driver", "passenger", "rear"]}, {"integer": "temperature_c", "minimum": 18, "maximum": 32}]],
|
||||
"frames": [
|
||||
{"key": "front", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "climate_temperature", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"starts": {"driver": 32, "passenger": 40}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_temperature_match"},
|
||||
{"key": "rear", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "climate_temperature", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"starts": {"rear": 48}}, "repeat": {"kind": "fixed", "count": 1, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_temperature_match"}
|
||||
],
|
||||
"evidence": "matrix_and_vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.climate.control",
|
||||
"channel": 1,
|
||||
"domain": "climate",
|
||||
"capabilities": ["climate.set_control_mode"],
|
||||
"help": "ac control <manual|auto>",
|
||||
"patterns": [[{"literal": "ac"}, {"literal": "control"}, {"enum": "mode", "values": ["manual", "auto"]}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"mode": {"start_bit": 16, "bit_length": 2, "values": {"manual": 1, "auto": 2}}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_control_match"}],
|
||||
"evidence": "matrix_and_vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.climate.cycle",
|
||||
"channel": 1,
|
||||
"domain": "climate",
|
||||
"capabilities": ["climate.set_cycle_mode"],
|
||||
"help": "ac cycle <internal|external>",
|
||||
"patterns": [[{"literal": "ac"}, {"literal": "cycle"}, {"enum": "mode", "values": ["internal", "external"]}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"mode": {"start_bit": 19, "bit_length": 2, "values": {"internal": 1, "external": 2}}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_cycle_match"}],
|
||||
"evidence": "matrix_and_vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.outlet.switch",
|
||||
"channel": 1,
|
||||
"domain": "outlet",
|
||||
"capabilities": ["outlet.set_switch_state"],
|
||||
"help": "outlet <driver-left|driver-right|passenger-left|passenger-right> <open|close>",
|
||||
"patterns": [[{"literal": "outlet"}, {"enum": "target", "values": ["driver-left", "driver-right", "passenger-left", "passenger-right"]}, {"enum": "state", "values": ["open", "close"]}]],
|
||||
"frames": [{
|
||||
"key": "command", "frame_id": "0x4EF", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "static_variant", "base_payload_hex": "00 00 00 00 00 00 00 00",
|
||||
"payload_variants": {
|
||||
"driver-left:open": "02 FF FF FF FF 01 FF FF",
|
||||
"driver-left:close": "02 FF FF FF FF 02 FF FF",
|
||||
"driver-right:open": "02 FF FF FF FF 04 FF FF",
|
||||
"driver-right:close": "02 FF FF FF FF 08 FF FF",
|
||||
"passenger-left:open": "03 FF FF FF FF 01 00 00",
|
||||
"passenger-left:close": "03 FF FF FF FF 02 00 00",
|
||||
"passenger-right:open": "03 FF FF FF FF 04 00 00",
|
||||
"passenger-right:close": "03 FF FF FF FF 08 00 00"
|
||||
},
|
||||
"repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x4BB", "0x37B"], "feedback_policy": "outlet_switch_state_match"
|
||||
}],
|
||||
"evidence": "vehicle_differential_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.outlet.percent",
|
||||
"channel": 1,
|
||||
"domain": "outlet",
|
||||
"capabilities": ["outlet.set_position_percent"],
|
||||
"help": "outlet <target> percent <0-100> <0-100>",
|
||||
"patterns": [[{"literal": "outlet"}, {"enum": "target", "values": ["driver-left", "driver-right", "passenger-left", "passenger-right"]}, {"literal": "percent"}, {"integer": "horizontal_pct", "minimum": 0, "maximum": 100}, {"integer": "vertical_pct", "minimum": 0, "maximum": 100}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x4EF", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "outlet_calibrated_coordinates", "base_payload_hex": "00 FF FF FF FF 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x2F5", "0x37B"], "feedback_policy": "outlet_position_match"}],
|
||||
"special_handler": "outlet_percent_to_calibrated_position"
|
||||
},
|
||||
{
|
||||
"id": "can1.outlet.position",
|
||||
"channel": 1,
|
||||
"domain": "outlet",
|
||||
"capabilities": ["outlet.set_calibrated_position"],
|
||||
"help": "outlet <target> position <-128..126> <-128..126>",
|
||||
"patterns": [[{"literal": "outlet"}, {"enum": "target", "values": ["driver-left", "driver-right", "passenger-left", "passenger-right"]}, {"literal": "position"}, {"integer": "horizontal_value", "minimum": -128, "maximum": 126}, {"integer": "vertical_value", "minimum": -128, "maximum": 126}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x4EF", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "outlet_calibrated_coordinates", "base_payload_hex": "00 FF FF FF FF 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x2F5", "0x37B"], "feedback_policy": "outlet_position_match"}],
|
||||
"special_handler": "outlet_calibrated_position"
|
||||
},
|
||||
{
|
||||
"id": "can1.seat.driver_thermal",
|
||||
"channel": 1,
|
||||
"domain": "seat",
|
||||
"capabilities": ["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"],
|
||||
"help": "seat driver <vent|heat> <off|low|high>",
|
||||
"patterns": [[{"literal": "seat"}, {"literal": "driver"}, {"enum": "feature", "values": ["vent", "heat"]}, {"enum": "level", "values": ["off", "low", "high"]}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x431", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "driver_seat_thermal", "base_payload_hex": "00 00 00 00 00 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x4FA"], "feedback_policy": "driver_seat_thermal_match"}],
|
||||
"evidence": "vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.seat.driver_massage",
|
||||
"channel": 1,
|
||||
"domain": "seat",
|
||||
"capabilities": ["seat.set_driver_massage_native"],
|
||||
"help": "seat driver massage <off|strength weak|medium|strong|mode wave|pulse|stretch|slack|relax>",
|
||||
"patterns": [
|
||||
[{"literal": "seat"}, {"literal": "driver"}, {"literal": "massage"}, {"literal": "off"}],
|
||||
[{"literal": "seat"}, {"literal": "driver"}, {"literal": "massage"}, {"literal": "strength"}, {"enum": "strength", "values": ["weak", "medium", "strong"]}],
|
||||
[{"literal": "seat"}, {"literal": "driver"}, {"literal": "massage"}, {"literal": "mode"}, {"enum": "mode", "values": ["wave", "pulse", "stretch", "slack", "relax"]}]
|
||||
],
|
||||
"frames": [{"key": "command", "frame_id": "0x4C1", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "driver_seat_massage", "base_payload_hex": "12 00 00 00 00 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x4FA"], "feedback_policy": "driver_seat_massage_match"}],
|
||||
"evidence": "vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.seat.driver_axis",
|
||||
"channel": 1,
|
||||
"domain": "seat",
|
||||
"capabilities": ["seat.set_driver_axis_native"],
|
||||
"help": "seat driver <horizontal|height|backrest|basin> <direction|stop> [100-2000]",
|
||||
"patterns": [
|
||||
[{"literal": "seat"}, {"literal": "driver"}, {"enum": "axis", "values": ["horizontal", "backrest"]}, {"enum": "direction", "values": ["forward", "backward"]}],
|
||||
[{"literal": "seat"}, {"literal": "driver"}, {"enum": "axis", "values": ["horizontal", "backrest"]}, {"enum": "direction", "values": ["forward", "backward"]}, {"integer": "duration_ms", "minimum": 100, "maximum": 2000}],
|
||||
[{"literal": "seat"}, {"literal": "driver"}, {"enum": "axis", "values": ["height", "basin"]}, {"enum": "direction", "values": ["up", "down"]}],
|
||||
[{"literal": "seat"}, {"literal": "driver"}, {"enum": "axis", "values": ["height", "basin"]}, {"enum": "direction", "values": ["up", "down"]}, {"integer": "duration_ms", "minimum": 100, "maximum": 2000}],
|
||||
[{"literal": "seat"}, {"literal": "driver"}, {"enum": "axis", "values": ["horizontal", "height", "backrest", "basin"]}, {"literal": "stop"}]
|
||||
],
|
||||
"frames": [
|
||||
{"key": "motion", "frame_id": "0x4C1", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "driver_seat_axis", "base_payload_hex": "05 00 00 00 00 00 00 00", "repeat": {"kind": "duration_ceiling", "count": 0, "period_ms": 100, "default_duration_ms": 500}, "feedback_ids": ["0x494"], "feedback_policy": "activity_then_forced_stop"},
|
||||
{"key": "stop", "frame_id": "0x4C1", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "static", "base_payload_hex": "05 00 00 00 00 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x494"], "feedback_policy": "submitted_stop"}
|
||||
],
|
||||
"special_handler": "driver_seat_axis"
|
||||
},
|
||||
{
|
||||
"id": "can1.seat.driver_memory",
|
||||
"channel": 1,
|
||||
"domain": "seat",
|
||||
"capabilities": ["seat.set_driver_position_native", "seat.recall_driver_memory_native"],
|
||||
"help": "seat driver position <1-3> | seat driver memory recall <1-3>",
|
||||
"patterns": [
|
||||
[{"literal": "seat"}, {"literal": "driver"}, {"literal": "position"}, {"integer": "slot", "minimum": 1, "maximum": 3}],
|
||||
[{"literal": "seat"}, {"literal": "driver"}, {"literal": "memory"}, {"literal": "recall"}, {"integer": "slot", "minimum": 1, "maximum": 3}]
|
||||
],
|
||||
"frames": [{"key": "sequence", "frame_id": "0x4C1", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "learned_driver_memory_pair", "base_payload_hex": "16 00 00 00 00 00 00 00", "repeat": {"kind": "sequence", "count": 20, "period_ms": 50}, "feedback_ids": ["0x494"], "feedback_policy": "fresh_initialized_status_then_post_status"}],
|
||||
"special_handler": "driver_seat_memory_recall"
|
||||
},
|
||||
{
|
||||
"id": "can1.light.indoor",
|
||||
"channel": 1,
|
||||
"domain": "light",
|
||||
"capabilities": ["interior_light.set_indoor_power"],
|
||||
"help": "light indoor <on|off>",
|
||||
"patterns": [[{"literal": "light"}, {"literal": "indoor"}, {"enum": "state", "values": ["on", "off"]}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x4F5", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "reading_light_all", "base_payload_hex": "00 00 03 FF FF 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x42E"], "feedback_policy": "indoor_light_match"}],
|
||||
"evidence": "vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.light.reading_all",
|
||||
"channel": 1,
|
||||
"domain": "light",
|
||||
"capabilities": ["interior_light.set_reading_power"],
|
||||
"help": "light reading all <on|off>",
|
||||
"patterns": [[{"literal": "light"}, {"literal": "reading"}, {"literal": "all"}, {"enum": "state", "values": ["on", "off"]}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x4F5", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "reading_light_all", "base_payload_hex": "00 00 03 FF FF 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x42E"], "feedback_policy": "indoor_light_match"}],
|
||||
"evidence": "vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.light.ambient_power",
|
||||
"channel": 1,
|
||||
"domain": "light",
|
||||
"capabilities": ["ambient_light.set_power"],
|
||||
"help": "light ambient <on|off>",
|
||||
"patterns": [[{"literal": "light"}, {"literal": "ambient"}, {"enum": "state", "values": ["on", "off"]}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x3FC", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "static_variant", "base_payload_hex": "00 00 00 00 00 00 00 00", "payload_variants": {"on": "00 36 00 75 00 80 00 00", "off": "00 31 00 75 00 80 00 00"}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x42E"], "feedback_policy": "ambient_power_match"}],
|
||||
"evidence": "vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.light.ambient_color",
|
||||
"channel": 1,
|
||||
"domain": "light",
|
||||
"capabilities": ["ambient_light.set_color_index"],
|
||||
"help": "light ambient color <1-127>",
|
||||
"patterns": [[{"literal": "light"}, {"literal": "ambient"}, {"literal": "color"}, {"integer": "color_index", "minimum": 1, "maximum": 127}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x3FC", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "ambient_color", "base_payload_hex": "00 30 00 75 00 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x42E"], "feedback_policy": "ambient_color_match"}],
|
||||
"evidence": "vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.light.ambient_brightness",
|
||||
"channel": 1,
|
||||
"domain": "light",
|
||||
"capabilities": ["ambient_light.set_brightness_level"],
|
||||
"help": "light ambient brightness <1-5>",
|
||||
"patterns": [[{"literal": "light"}, {"literal": "ambient"}, {"literal": "brightness"}, {"integer": "brightness_level", "minimum": 1, "maximum": 5}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x3FC", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "ambient_brightness", "base_payload_hex": "00 30 00 75 00 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x42E"], "feedback_policy": "ambient_brightness_match"}],
|
||||
"evidence": "vehicle_trace_verified"
|
||||
},
|
||||
{
|
||||
"id": "can1.shade.motion",
|
||||
"channel": 1,
|
||||
"domain": "roof",
|
||||
"capabilities": ["roof.set_shade_motion"],
|
||||
"help": "shade <open|close|half|stop>",
|
||||
"patterns": [[{"literal": "shade"}, {"enum": "motion", "values": ["open", "close", "half", "stop"]}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x431", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "static_variant", "base_payload_hex": "00 00 00 00 00 00 00 00", "payload_variants": {"open": "10 00 00 00 00 00 00 00", "close": "20 00 00 00 00 00 00 00", "half": "30 00 00 00 00 00 00 00", "stop": "40 00 00 00 00 00 00 00"}, "repeat": {"kind": "fixed", "count": 1, "period_ms": 0}, "feedback_ids": ["0x41A"], "feedback_policy": "shade_position_or_stop_submission"}],
|
||||
"special_handler": "shade_preset"
|
||||
},
|
||||
{
|
||||
"id": "can1.shade.position",
|
||||
"channel": 1,
|
||||
"domain": "roof",
|
||||
"capabilities": ["roof.set_shade_position"],
|
||||
"help": "shade position <0-100>",
|
||||
"patterns": [[{"literal": "shade"}, {"literal": "position"}, {"integer": "position_pct", "minimum": 0, "maximum": 100}]],
|
||||
"frames": [{"key": "command", "frame_id": "0x431", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "shade_feedback_closed_loop", "base_payload_hex": "40 00 00 00 00 00 00 00", "repeat": {"kind": "special", "count": 1, "period_ms": 0}, "feedback_ids": ["0x41A"], "feedback_policy": "fresh_position_then_motion_then_stop"}],
|
||||
"special_handler": "shade_position"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Linux USBCANFD-200U wired dual-channel N7 runtime.
|
||||
COGNITIVE_AGENT_VEHICLE_CAN_ENABLED=true
|
||||
COGNITIVE_AGENT_VEHICLE_CAN_BACKEND=zlgcan-linux
|
||||
COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL=1
|
||||
COGNITIVE_AGENT_VEHICLE_CAN_REQUIRED_CHANNEL=1
|
||||
COGNITIVE_AGENT_VEHICLE_CAN_ARBITRATION_BITRATE=500000
|
||||
COGNITIVE_AGENT_VEHICLE_CAN_DATA_BITRATE=2000000
|
||||
COGNITIVE_AGENT_VEHICLE_CAN_TERMINATION_ENABLED=false
|
||||
|
||||
COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED=true
|
||||
COGNITIVE_AGENT_SEAT_CAN_BACKEND=zlgcan-linux
|
||||
COGNITIVE_AGENT_SEAT_CAN_CHANNEL=0
|
||||
COGNITIVE_AGENT_SEAT_CAN_BITRATE=500000
|
||||
COGNITIVE_AGENT_SEAT_CAN_TERMINATION_ENABLED=false
|
||||
COGNITIVE_AGENT_SEAT_AUTO_FLASH_ENABLED=false
|
||||
COGNITIVE_AGENT_SEAT_POSITION_SYNC_ENABLED=false
|
||||
|
||||
COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE=33
|
||||
COGNITIVE_AGENT_ZLGCAN_DEVICE_INDEX=0
|
||||
COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY=/home/orin/project511/project-code/canfd/libusbcanfd.so
|
||||
COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED=false
|
||||
|
||||
# Local supervised bench only. Keep token approval enabled for shared/remote use.
|
||||
COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED=false
|
||||
@@ -7,6 +7,18 @@ metadata:
|
||||
spec:
|
||||
domain: outlet
|
||||
capabilities:
|
||||
- name: outlet.set_switch_state
|
||||
description: Open or close one front motorized air outlet using the byte-exact head-unit CAN FD request observed on the vehicle.
|
||||
target_devices: [motorized_air_outlet]
|
||||
target_scopes: [driver_left, driver_right, passenger_left, passenger_right]
|
||||
parameters:
|
||||
- name: state
|
||||
type: string
|
||||
required: true
|
||||
allowed_values: [open, close]
|
||||
description: Explicit switch state for exactly one air outlet.
|
||||
risk_level: medium
|
||||
confirmation_policy: on_ambiguity
|
||||
- name: outlet.set_position_percent
|
||||
description: Set both axes of one calibrated front air outlet using normalized percentages through vehicle CAN FD.
|
||||
target_devices: [motorized_air_outlet]
|
||||
|
||||
@@ -3,6 +3,38 @@
|
||||
"$id": "https://cognitive-agent.local/schemas/command-plan.schema.json",
|
||||
"title": "CommandPlan",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"generatedProtocolContract": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["source_version", "rule_id", "frame_key", "frame_id", "format", "brs", "dlc", "repeat", "feedback_ids", "feedback_policy"],
|
||||
"properties": {
|
||||
"source_version": {"type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$"},
|
||||
"rule_id": {"type": "string", "pattern": "^can[01]\\.[a-z0-9_.]+$"},
|
||||
"frame_key": {"type": "string", "minLength": 1},
|
||||
"frame_id": {"type": "string", "pattern": "^0x[0-7][0-9A-Fa-f]{0,2}$"},
|
||||
"format": {"type": "string", "enum": ["classic_can", "can_fd"]},
|
||||
"brs": {"type": "boolean"},
|
||||
"dlc": {"type": "integer", "minimum": 1, "maximum": 64},
|
||||
"repeat": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": ["kind", "count", "period_ms"],
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": ["fixed", "special", "duration_ceiling", "sequence"]},
|
||||
"count": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"period_ms": {"type": "integer", "minimum": 0, "maximum": 60000}
|
||||
}
|
||||
},
|
||||
"feedback_ids": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {"type": "string", "pattern": "^0x[0-7][0-9A-Fa-f]{0,2}$"}
|
||||
},
|
||||
"feedback_policy": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["apiVersion", "kind", "metadata", "spec"],
|
||||
"properties": {
|
||||
@@ -41,6 +73,7 @@
|
||||
"operation_ref": {"type": "string", "minLength": 2},
|
||||
"bus": {"type": "string", "pattern": "^[a-z][a-z0-9_-]*$"},
|
||||
"message": {"type": "string", "minLength": 1},
|
||||
"protocol": {"$ref": "#/$defs/generatedProtocolContract"},
|
||||
"signals": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
@@ -85,6 +118,7 @@
|
||||
"operation_ref": {"type": "string", "minLength": 2},
|
||||
"bus": {"type": "string", "pattern": "^[a-z][a-z0-9_-]*$"},
|
||||
"message": {"type": "string", "minLength": 1},
|
||||
"protocol": {"$ref": "#/$defs/generatedProtocolContract"},
|
||||
"signals": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
@@ -120,4 +154,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
"properties": {
|
||||
"mode": {"type": "string", "enum": ["dry_run", "hil", "vehicle"]},
|
||||
"status": {"type": "string", "enum": ["succeeded", "partially_succeeded", "failed", "cancelled"]},
|
||||
"verification_status": {
|
||||
"type": "string",
|
||||
"enum": ["verified_success", "submitted_unverified", "failed_or_blocked"],
|
||||
"description": "Highest task evidence level: feedback verified, transport submitted only, or submission blocked/failed."
|
||||
},
|
||||
"started_at": {"type": "string", "format": "date-time"},
|
||||
"finished_at": {"type": "string", "format": "date-time"},
|
||||
"physical_state": {
|
||||
@@ -42,4 +47,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"capability": {"type": "string", "pattern": "^[a-z][a-z0-9_]*\\.[a-z][a-z0-9_]*$"},
|
||||
"target": {"$ref": "common.schema.json#/$defs/target"},
|
||||
"parameters": {"type": "object"},
|
||||
"parse_source": {
|
||||
"type": "string",
|
||||
"enum": ["local_rule", "cloud_model", "cloud_supplement", "direct_control"]
|
||||
},
|
||||
"motion": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# CAN0 副驾座椅控制指令白名单
|
||||
|
||||
- 适用项目:`Cognitive_Agent`
|
||||
- 适用分支:`feature/N7Web`
|
||||
- 核对日期:2026-08-25
|
||||
- 控制对象:CAN0 右前/副驾座椅
|
||||
- 协议依据:`passenger_seat_firmware/firmware` 与 `Passenger_Seat_Controller_demo` 中共同存在的经典 CAN 协议
|
||||
- 验证层级:固件源码、语义解析、参数门禁和离线编译;本次没有向真实 CAN 总线发送报文
|
||||
|
||||
CAN0 与 CAN1 必须分开解释。本文中的 `seat passenger ...` 只生成副驾座椅操作,不得改写为主驾 `seat driver ...`,也不得发往 CAN1。所有 CAN0 原始帧均为标准 11 位 ID、Classic CAN、DLC 8、无 BRS。
|
||||
|
||||
## 有效语义控制指令
|
||||
|
||||
| 类别 | 规范指令 | 参数约束 | 当前执行逻辑 |
|
||||
| --- | --- | --- | --- |
|
||||
| 水平绝对位置 | `seat passenger horizontal position <0-100>` | 整数百分比 | 有线和无线使用相同的固件原生 `0x300`:一般位置为 Opcode `0x01`;显式 0% 为安全归零 Opcode `0x0B`;均以 `0x301/0x302` 闭环确认 |
|
||||
| 坐盆/高度绝对位置 | `seat passenger basin position <0-100>` | 整数百分比;对应固件 Motor 1 | 同上 |
|
||||
| 靠背绝对位置 | `seat passenger backrest position <0-100>` | 整数百分比;对应固件 Motor 2 | 同上 |
|
||||
| 腿托绝对位置 | `seat passenger legrest position <0-100>` | 整数百分比;对应固件 Motor 3 | 同上 |
|
||||
| 水平相对移动 | `seat passenger horizontal relative <forward\|backward> <1-100>` | 移动量为整数百分比 | 先根据 CAN0 当前位置计算有界绝对目标;位置有效时使用闭环 Opcode `0x01`,位置未标定且移动量满足轴级限制时使用 Opcode `0x03` 有界定时回退;有线和无线完全一致 |
|
||||
| 坐盆/高度相对移动 | `seat passenger basin relative <up\|down> <1-100>` | 同上;当前 C++ 台架工具依据实测把 `up` 映射为归一化位置增加、`down` 映射为减少 | 同上;该方向修正仅限 CAN0,不影响 CAN1 |
|
||||
| 靠背相对移动 | `seat passenger backrest relative <forward\|backward> <1-100>` | 同上 | 同上 |
|
||||
| 腿托相对移动 | `seat passenger legrest relative <up\|down> <1-100>` | 同上 | 同上 |
|
||||
| 副驾通风 | `seat passenger vent <off\|low\|medium\|high>` | `low/medium/high` 对应 1/2/3 档 | 有线和无线均发送固件原生 `0x29E`,状态以 `0x4FA` 确认;无线模式把原始 CAN0 帧封装到 MQTT 控制话题 |
|
||||
| 副驾加热 | `seat passenger heat <off\|low\|medium\|high>` | `low/medium/high` 对应 1/2/3 档 | 有线和无线均在开启时先发固件原生 `0x3CE` 模式,再发 `0x29E`,状态以 `0x4FA` 确认 |
|
||||
| 木马模式 | `seat passenger dynamic rocking-horse` | 固件模式 ID 1 | 有线和无线均发送 `0x300` Opcode `0x0E`、ParamA=1,并以 `0x301/0x306` 确认 |
|
||||
| 摇篮模式 | `seat passenger dynamic cradle` | 固件模式 ID 2 | 有线和无线均发送 `0x300` Opcode `0x0E`、ParamA=2,并以 `0x301/0x306` 确认 |
|
||||
| 停止动态模式 | `seat passenger dynamic stop` | 无附加参数 | 有线和无线均发送 `0x300` Opcode `0x0F`、ParamA/ParamB=0 |
|
||||
|
||||
为了兼容现有页面,精确指令入口仍接受以下旧别名,但前端“已解析 CAN 控制指令”统一显示上表规范形式:
|
||||
|
||||
- `longitudinal` → `horizontal`
|
||||
- `height` → `basin`
|
||||
- `seat passenger dynamic start rocking-horse` → `seat passenger dynamic rocking-horse`
|
||||
- `seat passenger dynamic start cradle` → `seat passenger dynamic cradle`
|
||||
|
||||
## 原生 0x300 控制字段
|
||||
|
||||
| Byte | 字段 | 说明 |
|
||||
| ---: | --- | --- |
|
||||
| 0 | Opcode | `0x01` 绝对、`0x02` 固件原生相对、`0x03` 有界定时、`0x08/0x09` 停止、`0x0B` 已知量程归零、`0x0E/0x0F` 启停动态模式 |
|
||||
| 1 | Control | bit0..1 Motor,bit2..3 Direction,bit4..5 Protocol Version=1 |
|
||||
| 2..3 | Sequence | uint16 little-endian |
|
||||
| 4..5 | ParamA | 绝对目标 0..10000;原生相对量为 int16;定时时长 100..60000 ms;归零密钥 `0x4B52`;动态模式为 1/2 |
|
||||
| 6..7 | ParamB | 绝对/相对超时 0 或 100..60000 ms;定时 Duty×10 为 300..1000;归零超时 10..600 s;动态模式必须为 0 |
|
||||
|
||||
当前自然语言相对移动采用“读取当前位置→计算目标→闭环执行”的安全策略,不直接无条件暴露 Opcode `0x02`。这是执行策略与语义命令的区别:用户仍能看到相对语义,底层可根据位置是否有效选择更安全的原始帧序列。
|
||||
|
||||
## CAN0 状态反馈与前端映射
|
||||
|
||||
| 反馈 ID | 固件含义 | 后端/前端映射 |
|
||||
| --- | --- | --- |
|
||||
| `0x301` | 系统状态、ACK、拒绝原因、有效轴掩码 | 作为命令 ACK 和安全诊断,不伪装成位置 |
|
||||
| `0x302` | 单轴任务、方向、位置有效位、故障、归一化位置 | Motor 0→前后 `longitudinal`;1→高度 `cushion_tilt`;2→靠背 `backrest_recline`;3→腿托 `legrest_height`;`Value_X100 / 100` 显示为 0–100% |
|
||||
| `0x303` | Hall、量程、速度、最近电机事件 | 诊断缓存,不覆盖 `0x302` 的权威位置 |
|
||||
| `0x304` | 单轴电流与堵转诊断 | 诊断缓存 |
|
||||
| `0x305` | 风扇/加热电流诊断 | 诊断缓存 |
|
||||
| `0x306` | 木马/摇篮模式、阶段、周期、故障掩码 | `passenger.dynamic_mode`,前端显示运行/空闲/故障 |
|
||||
| `0x4FA` | 副驾通风和加热状态 | `passenger.ventilation`、`passenger.heating` |
|
||||
| `0x6A0/0x6A2` | 旧车机 seat-ota 查询/应答 | 保留兼容;位置查询应答转换为同一内部 `0x302` 状态,不与原生状态重复解释 |
|
||||
|
||||
`0x6A0/0x6A2` 只允许作为旧车机状态查询兼容输入。自然语言、设备控件和
|
||||
无线 MQTT 控制白名单均禁止发送 `0x6A0`;无线只是把原生 CAN0 帧封装进
|
||||
`n7/n7-headunit/can/command`,不会改变 CAN ID 或载荷。
|
||||
|
||||
无线状态来自 `n7/n7-headunit/can/state` 时,以上字段使用 `wireless_mqtt_ch0` 标记;有线台架使用 `physical_can`。前端只把这些物理来源显示为实车反馈。
|
||||
|
||||
## 自然语言示例
|
||||
|
||||
- “把副驾座椅前后位置调到 35%” → `seat passenger horizontal position 35`
|
||||
- “副驾坐盆向上调 10%” → `seat passenger basin relative up 10`
|
||||
- “副驾靠背向后移动 15%,空调设为 24 度” → CAN0 `seat passenger backrest relative backward 15` + CAN1 `ac temp driver 24`
|
||||
- “开启木马模式并打开氛围灯” → CAN0 `seat passenger dynamic rocking-horse` + CAN1 `light ambient on`
|
||||
- “关闭摇篮模式,风量调到 2 档,打开遮阳帘” → CAN0 `seat passenger dynamic stop` + CAN1 `ac fan 2` + `shade open`
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 百分比越界、非法方向、未知轴和不在枚举内的动态模式会在生成 CAN 帧前拒绝。
|
||||
- 相对移动不会使用虚构位置计算目标;位置无效时仅允许编译器已限制时长、方向和 Duty 的 `0x03` 回退,否则拒绝。
|
||||
- MQTT Broker 接受消息只代表已提交;车辆动作是否完成仍以 CAN0 状态反馈为准。
|
||||
- 固件还实现全量程校准、清故障和查询等维护 Opcode,但它们不属于面向自然语言和前端设备控件的控制白名单。
|
||||
@@ -0,0 +1,88 @@
|
||||
# CAN1 控制指令白名单
|
||||
|
||||
- 适用项目:`Cognitive_Agent`
|
||||
- 适用分支:`feature/N7Web`
|
||||
- 核对日期:2026-08-24
|
||||
- 验证层级:自然语言解析、精确指令解析和 CAN1 白名单编译通过;未在本次核对中发送真实 CAN 帧。
|
||||
|
||||
“发送逻辑”列以当前执行器代码为准。有线模式按该顺序直接提交 CAN1 帧;无线 MQTT 模式将每个原始 CAN 帧分别封装为 1 条 `n7/n7-headunit/can/command` 消息(默认 QoS 1)。因此表中“3 帧,间隔 100 ms”在无线模式下表示后端连续发布 3 条 MQTT 消息,每条消息内部仍为 `repeat: 1`、`intervalMs: 0`。MQTT 客户端或 Broker 接受消息只代表已提交,车辆是否完成动作仍以状态话题的反馈帧为准。
|
||||
|
||||
## 有效白名单
|
||||
|
||||
| 类别 | 有效指令 | 参数约束 | 发送逻辑 |
|
||||
| --- | --- | --- | --- |
|
||||
| 后排门锁 | `door rear <lock\|unlock>` | 仅后排门锁 | `0x32B` 经典 CAN,发送 1 帧;反馈 ID `0x407` |
|
||||
| 车窗动作 | `window <all\|front\|rear\|front-left\|front-right\|rear-left\|rear-right> <open\|close\|stop>` | 目标和动作必须使用列出的枚举值 | `0x431` CAN FD+BRS,连续 3 帧,相邻帧间隔 100 ms;反馈 ID `0x388` |
|
||||
| 单窗位置 | `window <front-left\|front-right\|rear-left\|rear-right> position <0-100>` | 仅支持单个车窗,位置为整数百分比 | 先等待最长 2500 ms 的有效 `0x388`;已在目标 ±3% 内则发送 0 帧。否则用 `0x431` CAN FD+BRS 发送 3 帧启动命令(间隔 100 ms),最长闭环监听 `0x388` 12 s;无论到位或超时,最后强制发送 3 帧停止命令(间隔 100 ms) |
|
||||
| 空调电源 | `ac <on\|off>` | 整车 HVAC 电源 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
|
||||
| 空调风量 | `ac fan <1-7>` | 只接受 1–7 的整数 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
|
||||
| 出风模式 | `ac mode <face\|face-feet\|feet\|feet-defrost\|defrost\|face-feet-defrost\|face-defrost>` | 仅接受列出的 7 种模式 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
|
||||
| 空调温度 | `ac temp <driver\|passenger\|rear> <18-32>` | 温度只接受 18–32 的整数 | `0x1DE` 经典 CAN;`driver`/`passenger` 各发 3 帧(间隔 100 ms),`rear` 发 1 帧;反馈 ID `0x404` |
|
||||
| 空调控制模式 | `ac control <manual\|auto>` | 手动或自动 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
|
||||
| 空调循环 | `ac cycle <internal\|external>` | 内循环或外循环 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
|
||||
| 单个风口开关 | `outlet <driver-left\|driver-right\|passenger-left\|passenger-right> <open\|close>` | 必须明确指定四个前排电动风口之一;不提供隐式默认目标 | `0x4EF` CAN FD+BRS,使用车机差分抓包确认的逐字节请求,连续 3 帧,间隔 100 ms;反馈 ID `0x4BB`/`0x37B` |
|
||||
| 风口百分比 | `outlet <driver-left\|driver-right\|passenger-left\|passenger-right> percent <0-100> <0-100>` | 依次为左右、上下整数百分比 | `0x4EF` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x2F5`/`0x37B` |
|
||||
| 风口标定坐标 | `outlet <区域> position <左右坐标> <上下坐标>` | 必须同时满足通用有符号范围和对应风口标定范围 | `0x4EF` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x2F5`/`0x37B` |
|
||||
| 主驾座椅通风/加热 | `seat driver <vent\|heat> <off\|low\|high>` | `front-left` 可作为主驾输入兼容别名,规范输出仍为 `driver` | `0x431` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
|
||||
| 主驾座椅按摩关闭 | `seat driver massage off` | 仅关闭动作 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
|
||||
| 主驾座椅按摩强度 | `seat driver massage strength <weak\|medium\|strong>` | `1\|2\|3` 也可作为输入兼容别名,规范输出为单词枚举 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
|
||||
| 主驾座椅按摩模式 | `seat driver massage mode <wave\|pulse\|stretch\|slack\|relax>` | 仅接受列出的 5 种模式;自然语言“打开主驾座椅按摩”使用已验证的 `relax` 模式启动 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
|
||||
| 主驾座椅水平调节 | `seat driver horizontal <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
|
||||
| 主驾座椅高度调节 | `seat driver height <up\|down> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
|
||||
| 主驾座椅靠背调节 | `seat driver backrest <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
|
||||
| 主驾座椅坐盆调节 | `seat driver basin <up\|down> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
|
||||
| 主驾座椅停止 | `seat driver <horizontal\|height\|backrest\|basin> stop` | `stop` 后不允许附加持续时间 | `0x4C1` CAN FD+BRS,直接连续发送 3 帧 STOP,间隔 100 ms;反馈 ID `0x494` |
|
||||
| 主驾座椅记忆 | `seat driver position <1-3>` | 需要先由操作员完成 PAD 只读学习 | `0x4C1` CAN FD+BRS;下发前最长等待 2500 ms 的新鲜 `0x494`,且四轴初始化均为真;然后将已学习的 `0x16 → 0x15` 交替序列发送 10 轮,共 20 帧,间隔 50 ms;发送后最长等待 1200 ms 的 `0x494` |
|
||||
| 主驾座椅记忆兼容别名 | `seat driver memory recall <1-3>` | 与 `seat driver position <1-3>` 使用同一已学习帧序列 | 发送逻辑与 `seat driver position <1-3>` 完全相同:`0x4C1` CAN FD+BRS,四轴初始化门禁通过后发送 20 帧 `0x16/0x15` 交替序列,间隔 50 ms |
|
||||
| 室内灯总开关 | `light indoor <on\|off>` | 整车室内灯总开关 | `0x4F5` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
|
||||
| 全部阅读灯 | `light reading all <on\|off>` | CAN1 仅验证 `all` 目标 | `0x4F5` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
|
||||
| 氛围灯开关 | `light ambient <on\|off>` | 氛围灯总开关 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
|
||||
| 氛围灯颜色 | `light ambient color <1-127>` | 车型颜色索引,只接受整数 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
|
||||
| 氛围灯亮度 | `light ambient brightness <1-5>` | 只接受 1–5 的整数档位 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
|
||||
| 遮阳帘动作 | `shade <open\|close\|half\|stop>` | 0/50/100 位置与已验证预设动作复用 | `0x431` 经典 CAN;`open/close/half` 先发 1 帧预设命令,最长监听 `0x41A` 15 s,在匹配目标位置但仍在运动时追加 1 帧 STOP 并确认停止;`stop` 发 1 帧 STOP 并等待停止反馈 |
|
||||
| 遮阳帘位置 | `shade position <0-100>` | 中间位置使用状态反馈闭环并自动停止 | 0/50/100 按上述预设逻辑发送。其他位置先等待最长 2500 ms 的新鲜 `0x41A`;已在目标 ±2% 内且停止则发 0 帧,已到位但仍运动则发 1 帧 STOP。否则用 `0x431` 经典 CAN 发 1 帧开/关启动命令,最长监听 15 s,并在 `finally` 中强制发 1 帧 STOP,再确认位置与停止状态 |
|
||||
|
||||
## 单个风口开关帧
|
||||
|
||||
以下请求来自车机逐个开关四个风口时的 CAN1 差分抓包。每条均为标准 ID `0x4EF`、CAN FD+BRS、DLC 8,连续发送 3 次,间隔约 100 ms:
|
||||
|
||||
| 目标 | `open` 数据 | `close` 数据 |
|
||||
| --- | --- | --- |
|
||||
| `driver-left` | `02 FF FF FF FF 01 FF FF` | `02 FF FF FF FF 02 FF FF` |
|
||||
| `driver-right` | `02 FF FF FF FF 04 FF FF` | `02 FF FF FF FF 08 FF FF` |
|
||||
| `passenger-left` | `03 FF FF FF FF 01 00 00` | `03 FF FF FF FF 02 00 00` |
|
||||
| `passenger-right` | `03 FF FF FF FF 04 00 00` | `03 FF FF FF FF 08 00 00` |
|
||||
|
||||
副驾两个请求的末两字节必须保持为 `00 00`,不能复用主驾请求的 `FF FF`。`0x4BB` 位置签名及部分网关上的 `0x37B/0x50` 开关枚举用于协议反馈匹配;反馈匹配和 CAN ACK 都不能替代实体风口动作核对。
|
||||
|
||||
## 风口标定坐标范围
|
||||
|
||||
| 风口 | 左右范围 | 上下范围 |
|
||||
| --- | ---: | ---: |
|
||||
| `driver-left` | -25–40 | -40–30 |
|
||||
| `driver-right` | -40–30 | -40–30 |
|
||||
| `passenger-left` | -10–40 | -40–30 |
|
||||
| `passenger-right` | -40–5 | -40–30 |
|
||||
|
||||
风口原始坐标语法层的通用范围为 -128–126,但只有同时落在上表对应风口实际标定范围内才能通过编译。
|
||||
|
||||
## 能力层扩展与非白名单入口
|
||||
|
||||
| 指令/能力 | 当前状态 | 发送逻辑 |
|
||||
| --- | --- | --- |
|
||||
| `ac compressor <on\|off>` | `climate.set_ac` 在后端能力层可编译,但当前没有对应的直接精确文本解析入口 | 若由受信能力层调用:`0x1DE` 经典 CAN,3 帧,间隔 100 ms,反馈 ID `0x404`;但本项不是直接指令白名单入口 |
|
||||
| `door vehicle <lock\|unlock>` | 可解析,但当前 CH1 实车验证未观察到有效整车门锁动作,因此会被安全拒绝,不属于可下发白名单 | 安全门禁拒绝,发送 0 帧 |
|
||||
| `ac defrost <on\|off>` | 存在旧解析入口,但当前 CAN1 运行时未实现;前除霜应使用 `ac mode defrost` | 编译门禁拒绝,发送 0 帧 |
|
||||
|
||||
## CAN0 边界
|
||||
|
||||
本文档只描述 CAN1 车身控制白名单。CAN0 只用于副驾座椅,其状态、轴位置、热舒适和动态模式控制必须依据 `/home/orin/project511/passenger_seat_firmware` 中的固件原生协议独立处理,不得使用本 CAN1 白名单生成或猜测 CAN0 帧。
|
||||
|
||||
## 实现与回归测试
|
||||
|
||||
- 精确命令和自然语言匹配:`src/cognitive_agent/application/native_can_intent.py`
|
||||
- 能力、参数及 CAN 帧编译门禁:`src/cognitive_agent/application/native_can_runtime.py`
|
||||
- 风口标定范围:`platforms/t2g_cabin_bench/calibration/body_can.yaml`
|
||||
- 完整白名单回归测试:`tests/unit/test_can1_semantic_whitelist.py`
|
||||
|
||||
白名单回归测试展开覆盖 129 条有效命令(包括内/外循环和 8 条单风口开关组合),并验证越界、非整数、非法枚举和超出风口标定范围的指令不能通过白名单门禁。`can_read_trans self-test` 还会将这 129 条指令按 Console 单批 32 条的上限拆成 5 批,逐条验证 CAN1 路由,并用一批 30 条指令覆盖所有 CAN1 控制类别的混合校验。
|
||||
@@ -0,0 +1,111 @@
|
||||
# 自然语言转 CAN 控制指令测试报告
|
||||
|
||||
## 1. 测试结论
|
||||
|
||||
| 项目 | 结果 |
|
||||
| --- | ---: |
|
||||
| 测试日期 | 2026-08-25 |
|
||||
| pytest 测试入口 | 2 |
|
||||
| 生成式组合用例 | 1133 |
|
||||
| 定向 CAN0/CAN1 组合用例 | 6 |
|
||||
| 实际执行子用例 | **1139** |
|
||||
| 通过 | **1139** |
|
||||
| 失败 | **0** |
|
||||
| 跳过 | **0** |
|
||||
| 通过率 | **100%** |
|
||||
| 执行耗时 | 35.42 s |
|
||||
|
||||
**结论:**当前本地规则能够将本次覆盖的自然语言控制指令正确解析为 CAN0/CAN1 语义控制指令;跨通道组合中,副驾座椅指令保持在 CAN0,主驾座椅、空调、灯光、车窗、门锁和遮阳帘等指令保持在 CAN1。白名单外指令未生成 CAN 控制流。
|
||||
|
||||
## 2. 测试方式
|
||||
|
||||
- 解析器:本地规则(`provider=local`)
|
||||
- 执行模式:`dry_run`
|
||||
- 硬件执行:关闭
|
||||
- 真实 CAN 报文下发:未进行
|
||||
- 测试入口:`tests/integration/test_natural_can_command_combinations.py`
|
||||
- 机器可执行用例:`tests/fixtures/natural_can_command_combination_suite.json`
|
||||
- 完整逐条用例:`docs/testing/自然语言转CAN组合测试用例.csv`
|
||||
|
||||
执行命令:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src:domains/seat/src .venv/bin/python -m pytest -q tests/integration/test_natural_can_command_combinations.py
|
||||
```
|
||||
|
||||
原始结果:
|
||||
|
||||
```text
|
||||
2 passed, 2 warnings, 1139 subtests passed in 35.42s
|
||||
```
|
||||
|
||||
2 个警告均为 `jsonschema.RefResolver` 弃用提示,不影响本次解析结果。
|
||||
|
||||
## 3. 1133 条生成式用例分布
|
||||
|
||||
### 3.1 按组合类型
|
||||
|
||||
| 用例类型 | 数量 | 说明 |
|
||||
| --- | ---: | --- |
|
||||
| atomic | 177 | 单条原子指令 |
|
||||
| pairwise | 300 | 两两组合 |
|
||||
| pairwise_separator_variant | 300 | 两两组合的不同分隔方式 |
|
||||
| three_command | 84 | 三指令组合 |
|
||||
| four_command | 126 | 四指令组合 |
|
||||
| cross_can0_can1 | 120 | CAN0/CAN1 跨通道组合 |
|
||||
| multi_command_scene | 10 | 常用多指令场景 |
|
||||
| safety_rejection | 16 | 白名单外和不安全输入拒绝 |
|
||||
| **合计** | **1133** | |
|
||||
|
||||
### 3.2 按 CAN 通道
|
||||
|
||||
| 预期通道 | 数量 | 说明 |
|
||||
| --- | ---: | --- |
|
||||
| CAN0 | 13 | 副驾座椅单通道控制 |
|
||||
| CAN1 | 899 | 车身、空调、主驾座椅、灯光等控制 |
|
||||
| CAN0 + CAN1 | 205 | 副驾座椅与车身功能组合控制 |
|
||||
| none | 16 | 安全拒绝,不应生成控制指令 |
|
||||
| **合计** | **1133** | |
|
||||
|
||||
## 4. 代表性解析结果
|
||||
|
||||
| 自然语言输入 | 通道 | 预期 CAN 语义控制指令/操作 | 结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 后排车门上锁 | CAN1 | `door rear lock` | 通过 |
|
||||
| 打开左前车窗 | CAN1 | `window front-left open` | 通过 |
|
||||
| 副驾驶座椅加热开到2档 | CAN0 | `seat.set_heating(state=on, level=2)` | 通过 |
|
||||
| 后排车门上锁,打开左前车窗,副驾驶座椅加热开到2档 | CAN0 + CAN1 | CAN1: `door rear lock`; CAN1: `window front-left open`; CAN0: `seat.set_heating(state=on, level=2)` | 通过 |
|
||||
| 开启木马模式并打开氛围灯 | CAN0 + CAN1 | CAN0: `seat.start_dynamic_mode(mode_id=1)`; CAN1: `light ambient on` | 通过 |
|
||||
| 开启动态摇篮模式,空调24度,打开遮阳帘 | CAN0 + CAN1 | CAN0: `seat.start_dynamic_mode(mode_id=2)`; CAN1: `ac temp driver 24`; CAN1: `shade open` | 通过 |
|
||||
| 副驾座椅前后调到20%、坐盆调到30%、靠背调到40%、腿托调到50%,空调风量3档,关闭全部车窗 | CAN0 + CAN1 | CAN0: 四轴绝对位置 20/30/40/50; CAN1: `ac fan 3`; CAN1: `window all close` | 通过 |
|
||||
| 副驾靠背向后移动15%,主驾温度设为25度,遮阳帘打开 | CAN0 + CAN1 | CAN0: 靠背相对后移 15%; CAN1: `ac temp driver 25`; CAN1: `shade open` | 通过 |
|
||||
| 主驾座椅通风4档 | none | 白名单外,不生成 CAN 指令 | 通过 |
|
||||
| 主驾进入零重力模式 | none | 未验证能力,不生成 CAN 指令 | 通过 |
|
||||
|
||||
## 5. 6 条新增定向 CAN0/CAN1 用例
|
||||
|
||||
| # | 自然语言 | CAN0 结果 | CAN1 结果 |
|
||||
| ---: | --- | --- | --- |
|
||||
| 1 | 把副驾座椅水平位置调到35%,空调温度设置为24度,打开氛围灯 | 水平绝对位置 35% | `ac temp driver 24`; `light ambient on` |
|
||||
| 2 | 副驾坐盆向上调整10%,关闭后排车窗,空调风量2档 | 坐盆相对上调 10% | `window rear close`; `ac fan 2` |
|
||||
| 3 | 开启木马模式并打开氛围灯 | 木马模式 | `light ambient on` |
|
||||
| 4 | 开启动态摇篮模式,空调24度,打开遮阳帘 | 摇篮模式 | `ac temp driver 24`; `shade open` |
|
||||
| 5 | 副驾座椅前后调到20%、坐盆调到30%、靠背调到40%、腿托调到50%,空调风量3档,关闭全部车窗 | 四轴绝对位置 | `ac fan 3`; `window all close` |
|
||||
| 6 | 副驾靠背向后移动15%,主驾温度设为25度,遮阳帘打开 | 靠背相对后移 15% | `ac temp driver 25`; `shade open` |
|
||||
|
||||
以上 6 条用例全部通过。
|
||||
|
||||
## 6. 覆盖能力
|
||||
|
||||
- CAN0:副驾座椅加热、通风、绝对位置、相对位置、木马模式和摇篮模式。
|
||||
- CAN1:门锁、车窗、空调、风口、主驾座椅、室内灯、阅读灯、氛围灯和遮阳帘。
|
||||
- 组合解析:两指令、三指令、四指令、场景化多指令以及 CAN0/CAN1 跨通道组合。
|
||||
- 位置语义:主驾、副驾、左前、右前、左后、右后、前排、后排。
|
||||
- 安全约束:越界档位、未验证能力和白名单外目标不生成 CAN 控制流。
|
||||
|
||||
## 7. 范围和限制
|
||||
|
||||
1. 本报告验证的是“自然语言 → CAN 语义控制指令/操作”解析层。
|
||||
2. 本次使用 `dry_run`,未向真实 CAN0/CAN1 总线或 MQTT Broker 下发报文。
|
||||
3. 通过本报告不等于实车执行器、USB-CAN 适配器、MQTT 转发和 ECU 物理动作已完成端到端验证。
|
||||
4. 如需验证真实执行,应单独进行有线 CAN 和无线 MQTT 台架测试,并记录原始报文、状态回馈和物理动作。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
# 自然语言转 CAN 控制指令组合测试说明
|
||||
|
||||
- 生成日期:2026-08-24
|
||||
- 总用例数:**1133**
|
||||
- 执行方式:本地规则、`dry_run`,不发送真实 CAN 帧
|
||||
- 完整逐条清单:`docs/testing/自然语言转CAN组合测试用例.csv`
|
||||
- 机器可执行数据:`tests/fixtures/natural_can_command_combination_suite.json`
|
||||
|
||||
## 覆盖数量
|
||||
|
||||
| 用例类型 | 数量 |
|
||||
| --- | ---: |
|
||||
| `atomic` | 177 |
|
||||
| `cross_can0_can1` | 120 |
|
||||
| `four_command` | 126 |
|
||||
| `multi_command_scene` | 10 |
|
||||
| `pairwise` | 300 |
|
||||
| `pairwise_separator_variant` | 300 |
|
||||
| `safety_rejection` | 16 |
|
||||
| `three_command` | 84 |
|
||||
|
||||
| 通道 | 数量 |
|
||||
| --- | ---: |
|
||||
| `can0` | 13 |
|
||||
| `can0+can1` | 205 |
|
||||
| `can1` | 899 |
|
||||
| `none` | 16 |
|
||||
|
||||
## 组合策略
|
||||
|
||||
1. 原子用例覆盖 CAN1 门锁、车窗、空调、风口、主驾座椅、灯光、遮阳帘,以及 CAN0 副驾座椅。
|
||||
2. 两两组合从 25 个互不冲突的控制槽中取组合,同时覆盖中文逗号和分号分隔形式。
|
||||
3. 三指令和四指令组合覆盖 CAN0/CAN1、座椅、空调、灯光、车窗、门锁、风口与遮阳帘的交叉。
|
||||
4. 跨通道组合明确检查副驾座椅留在 CAN0、主驾和车身控制留在 CAN1。
|
||||
5. 多指令场景覆盖观影、双座椅、全温区空调、泊车关闭、车窗与遮阳帘等常用场景。
|
||||
6. 安全用例要求白名单外目标、档位、能力和矛盾指令不生成 CAN 控制流。
|
||||
|
||||
## 执行
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src:domains/seat/src .venv/bin/python -m pytest -q tests/integration/test_natural_can_command_combinations.py
|
||||
```
|
||||
|
||||
重新生成清单:
|
||||
|
||||
```bash
|
||||
.venv/bin/python tools/generate_natural_can_command_suite.py
|
||||
```
|
||||
@@ -0,0 +1,208 @@
|
||||
# can_read_trans 双通道检测与控制
|
||||
|
||||
`can_read_trans` 是当前项目的 Linux C++ 命令行诊断程序。一个进程独占一台
|
||||
ZLG USBCANFD-200U,并同时启动两个通道:
|
||||
|
||||
| 通道 | 用途 | 总线格式 | 状态依据 | 控制依据 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| CAN0 | 右前/副驾座椅 | Classic CAN,500 kbit/s,11 位标准 ID | `0x301–0x306/0x4FA` | 副驾固件原生 `0x300/0x29E/0x3CE` |
|
||||
| CAN1 | 其余车辆设备 | 仲裁 500 kbit/s、数据 2 Mbit/s,Classic CAN + CAN FD/BRS | N7 CAN1 矩阵反馈 | 当前 CAN1 语义白名单 |
|
||||
|
||||
CAN0 的运动、通风、加热、木马和摇篮报文来自
|
||||
`/home/orin/project511/passenger_seat_firmware`。程序不会把旧车机
|
||||
`0x6A0/0x6A2` 当作控制协议。CAN1 的字节编译和反馈解析复用
|
||||
`/home/orin/project511/project-code/canfd/can_read_trans.cpp`,避免维护另一份
|
||||
可能漂移的 CAN1 实现。
|
||||
|
||||
## 构建和离线自检
|
||||
|
||||
```bash
|
||||
make can-read-trans
|
||||
make can-read-trans-test
|
||||
```
|
||||
|
||||
生成的程序位于:
|
||||
|
||||
```text
|
||||
build/can_read_trans/can_read_trans
|
||||
```
|
||||
|
||||
如 CAN 库不在默认目录,可在构建时覆盖:
|
||||
|
||||
```bash
|
||||
make can-read-trans CAN_READ_TRANS_CANFD_ROOT=/path/to/canfd
|
||||
```
|
||||
|
||||
离线自检只核对 ZLG ABI、CAN0 固件字段字节序、动态模式 payload、CAN1 已验证
|
||||
车窗 payload 和白名单拒绝规则,不打开 USB 设备,也不发送 CAN 帧:
|
||||
|
||||
```bash
|
||||
build/can_read_trans/can_read_trans self-test
|
||||
```
|
||||
|
||||
## 状态检测
|
||||
|
||||
持续监听两个通道,按 Ctrl-C 退出:
|
||||
|
||||
```bash
|
||||
build/can_read_trans/can_read_trans monitor
|
||||
```
|
||||
|
||||
监听 5 秒并打印最近状态汇总:
|
||||
|
||||
```bash
|
||||
build/can_read_trans/can_read_trans status --duration-ms 5000
|
||||
```
|
||||
|
||||
只跟踪 CAN0 或 CAN1 的每一条原始接收帧:
|
||||
|
||||
```bash
|
||||
build/can_read_trans/can_read_trans monitor --channel can0 --trace-all
|
||||
build/can_read_trans/can_read_trans monitor --channel can1 --trace-all
|
||||
```
|
||||
|
||||
`--trace-all` 强制只读,不能与 `send` 组合。程序同时轮询 CAN1 的
|
||||
`VCI_Receive` 和 `VCI_ReceiveFD`,不会因为只读了其中一个 SDK 队列而漏掉帧。
|
||||
|
||||
## 持续交互 Console
|
||||
|
||||
只读进入交互终端,可以持续查看状态和校验命令,但不会发送控制帧:
|
||||
|
||||
```bash
|
||||
build/can_read_trans/can_read_trans console
|
||||
```
|
||||
|
||||
确认台架净空、人员远离机构且急停可用后,显式允许本次会话发送白名单命令:
|
||||
|
||||
```bash
|
||||
build/can_read_trans/can_read_trans console --execute
|
||||
```
|
||||
|
||||
程序在同一进程中持续持有 CAN0/CAN1,后台接收线程不会在执行命令时停止,但只
|
||||
缓存解析结果,不会把周期状态持续打印到交互终端。输入一次 `status` 才会打印一次
|
||||
当时缓存的 CAN0/CAN1 状态快照。交互命令会根据两份白名单自动路由,不需要手工
|
||||
添加通道前缀:
|
||||
|
||||
```text
|
||||
can[all]> status
|
||||
can[all]> seat passenger backrest position 35
|
||||
[route] CAN0 <- seat passenger backrest position 35
|
||||
can[all]> ac fan 2
|
||||
[route] CAN1 <- ac fan 2
|
||||
can[all]> outlet driver-left close
|
||||
[route] CAN1 <- outlet driver-left close
|
||||
can[all]> batch seat passenger basin relative up 10 ; ac fan 2 ; light ambient on
|
||||
[batch validated] commands=3 execution=sequential stop-on-hard-failure
|
||||
can[all]> whitelist
|
||||
can[all]> mark before-shade-test
|
||||
can[all]> quit
|
||||
```
|
||||
|
||||
内置交互命令:
|
||||
|
||||
- `status`:一次性显示最近解析出的 CAN0/CAN1 状态,不发送查询帧;
|
||||
- `whitelist`:显示全部可发送语义命令;
|
||||
- `help`:显示 Console 帮助;
|
||||
- `mark <文本>`:打印带时间戳的调试标记;
|
||||
- `quit`/`exit`:复位两个通道、关闭设备并退出。
|
||||
|
||||
批量命令使用 `batch`,各条语义命令用英文分号分隔:
|
||||
|
||||
```text
|
||||
batch seat passenger basin relative up 10 ; ac fan 2 ; light ambient on
|
||||
```
|
||||
|
||||
程序先完成整批预校验,再发送任何帧。任意一条超出白名单、路由到被
|
||||
`--channel` 禁止的通道、命令为空,或同批混用 CAN0 动态模式启动与 CAN0 轴移动,
|
||||
都会拒绝整批且不发送帧。预校验通过后严格按输入顺序串行执行;构造、安全门禁或 SDK
|
||||
提交失败时立即停止,后续命令不再发送。单风口开关的 3 帧全部提交成功、但在 1500 ms
|
||||
内未收到 `0x4BB/0x37B` 匹配反馈时,记为 `submitted-unverified`,不误报实体动作成功,
|
||||
但会继续下一条相互独立的批量命令。最终汇总分别打印 `submitted`、`verified`和
|
||||
`unverified`。其他依赖闭环或前置状态的命令仍保持原有失败停止策略。单批最多 32 条,
|
||||
每条最多 160 个字符。
|
||||
|
||||
CAN1 的 129 条规范/边界白名单命令已在离线自测中拆成 5 批全部通过;另有一批
|
||||
30 条的混合用例覆盖门锁、车窗、空调、循环、单风口开关、风口位置、主驾座椅、灯光和遮阳帘。
|
||||
这证明它们能共同通过批量语义校验并按 CAN1 路由,不等同于无条件完成实体动作:
|
||||
单窗位置需要新鲜 `0x388`,风口标定坐标需要有效标定,座椅记忆需要 PAD 学习值和
|
||||
新鲜 `0x494`,遮阳帘位置需要 `0x41A` 闭环;运行时前置条件不满足会停止该批次。
|
||||
|
||||
可以用 `--channel can0` 或 `--channel can1` 限制本次会话。路由到未选择通道的
|
||||
命令会被拒绝。`console --trace-all` 是只读全帧跟踪模式;为防止抓包过程中误发,
|
||||
它不能与 `--execute` 同时使用。
|
||||
|
||||
## 白名单控制
|
||||
|
||||
先查看程序接受的完整语义命令:
|
||||
|
||||
```bash
|
||||
build/can_read_trans/can_read_trans whitelist
|
||||
```
|
||||
|
||||
发送必须同时显式给出 `send`、目标通道、白名单语义命令和 `--execute`。例如:
|
||||
|
||||
```bash
|
||||
build/can_read_trans/can_read_trans send \
|
||||
--channel can0 \
|
||||
--command "seat passenger backrest position 35" \
|
||||
--execute
|
||||
|
||||
build/can_read_trans/can_read_trans send \
|
||||
--channel can1 \
|
||||
--command "ac fan 2" \
|
||||
--execute
|
||||
```
|
||||
|
||||
以上命令会操作真实硬件,执行前必须确认台架净空、人员远离机构、急停可用,且
|
||||
车辆满足当前项目的安全条件。程序没有 `--id/--data` 一类任意原始帧发送入口;
|
||||
不在 CAN0 或 CAN1 语义白名单中的命令会在构造帧前被拒绝。
|
||||
|
||||
CAN0 相对移动不会直接信任此前无线链路生成的原始帧:
|
||||
|
||||
1. 先读取新鲜、有效的 `0x302` 当前位置;
|
||||
2. 计算限制在 0–100% 内的绝对目标;
|
||||
3. 采用固件原生 `0x300` 闭环执行;
|
||||
4. 只有当前位置不可用且相对量不超过该轴 15% 时,才使用固件限定时长和 Duty
|
||||
的 Opcode `0x03` 回退;程序不直接暴露 Opcode `0x02`;
|
||||
5. 未收到终态反馈时发送固件原生 STOP_ALL 安全帧。
|
||||
|
||||
当前台架已实测 `seat passenger basin relative up/down` 的物理方向与仓库中旧坐标
|
||||
说明相反,因此本 C++ 工具把 `up` 映射为 CAN0 归一化位置增加、`down` 映射为减少。
|
||||
此修正只作用于副驾 CAN0,不会改变 CAN1 主驾坐盆命令或任何 CAN1 帧。
|
||||
|
||||
热舒适命令必须先收到新鲜有效的 `0x4FA`,用它保留未被本次命令修改的另一项
|
||||
功能,防止“调通风”意外关闭加热,或反之。
|
||||
|
||||
CAN0 固件要求除 `STOP_ALL` 外的 16 位 sequence 按有符号环形顺序递增。工具重启
|
||||
后第一次控制若收到 `0x301 RejectReason=8 (SEQUENCE_OLD)`,程序不会猜测或遍历
|
||||
序号,而是使用固件定义的 `STOP_ALL (0x09)` 序号同步屏障;确认 ACK 且四轴均停止
|
||||
后,只重试原语义命令一次。若同步或重试仍失败,则保持 Console 可用并停止自动
|
||||
重试,避免与另一个 CAN0 命令源争抢序号所有权。
|
||||
|
||||
## 反馈判定
|
||||
|
||||
程序明确区分四个层级:
|
||||
|
||||
1. `VCI_Transmit/VCI_TransmitFD=1`:SDK/适配器完成提交;
|
||||
2. CAN ACK:至少一个正常节点确认该帧;
|
||||
3. 协议反馈:CAN0 的 `0x301/0x302/0x306/0x4FA` 或 CAN1 对应状态帧出现;
|
||||
4. 实体动作:机构或车辆设备确实完成动作。
|
||||
|
||||
SDK 返回 1 不能单独证明目标 ECU 接收或执行。CAN0 命令会匹配新的 Sequence、
|
||||
Opcode 和后续状态代次;CAN1 保留既有矩阵反馈等待逻辑。最终仍应结合实体动作、
|
||||
故障位和总线抓包判断。
|
||||
|
||||
## 设备占用和终端电阻
|
||||
|
||||
ZLG 设备应只有一个持有进程。运行本工具前,先停止正在占用同一 USB-CAN 盒的
|
||||
Web 后端、旧版 `can_read_trans` 或 CANPro;否则会出现 `VCI_OpenDevice` 失败。
|
||||
|
||||
两个通道的适配器内部 120 Ω 默认都关闭。只有确认适配器位于对应总线物理端点且
|
||||
拓扑确实缺少该端终端时,才显式增加:
|
||||
|
||||
```bash
|
||||
build/can_read_trans/can_read_trans monitor --can0-termination
|
||||
build/can_read_trans/can_read_trans monitor --can1-termination
|
||||
```
|
||||
|
||||
不要把“收不到帧”直接解释为“需要打开终端电阻”;应先核对供电、共地、CAN_H、
|
||||
@@ -0,0 +1,41 @@
|
||||
# CAN0/CAN1 共享语义协议清单
|
||||
|
||||
CAN 语义控制的单一来源是:
|
||||
|
||||
- `config/can_protocol/semantic_protocol.json`
|
||||
|
||||
该清单同时定义 CAN0/CAN1 的语义语法、能力映射、帧 ID、经典 CAN/CAN FD、BRS、DLC、基础帧模板、固定或动态重复策略、周期和反馈 ID。需要实时状态参与的窗口位置、遮阳帘位置、座椅相对移动和记忆位置仍由命名的 `special_handler` 执行闭环算法;这些算法不得另行定义帧 ID、重复节拍或反馈来源。
|
||||
|
||||
## 生成物
|
||||
|
||||
不要直接修改以下文件:
|
||||
|
||||
- `src/cognitive_agent/generated/can_semantic_protocol.py`
|
||||
- `tools/can_read_trans/generated/can_semantic_protocol.hpp`
|
||||
|
||||
更新清单后执行:
|
||||
|
||||
```bash
|
||||
make can-protocol-generate
|
||||
```
|
||||
|
||||
只检查生成物是否同步:
|
||||
|
||||
```bash
|
||||
make can-protocol-check
|
||||
```
|
||||
|
||||
`make validate`、`make test` 和 `make can-read-trans-test` 都会检查或重新生成协议视图。生成内容携带规范化源文件的 SHA-256,便于确认 Python 与 C++ 来自同一版本。
|
||||
|
||||
## 运行时接入
|
||||
|
||||
- Python CAN1 `NativeCanRuntime` 从生成模块取得支持能力、帧格式、帧 ID、重复策略和反馈 ID。
|
||||
- Python CAN0 `T2GSeatCompiler` 在每个物理命令中附带生成的协议契约;`HardwareSeatExecutor` 会在发送前校验矩阵编码的 ID、DLC 和格式,并使用契约中的反馈 ID。
|
||||
- C++ `can_read_trans` 使用生成头文件匹配 CAN0/CAN1 白名单、打印帮助、取得 CAN0 传输元数据和 CAN1 单风口精确 payload。其自检还会将生成模板与已验证的状态闭环编码器交叉比对。
|
||||
|
||||
## 修改规则
|
||||
|
||||
1. 仅在协议证据明确时修改帧模板、重复次数或反馈 ID。
|
||||
2. 新增命令时必须同时填写 `patterns`、`frames`、`repeat` 和 `feedback_ids`。
|
||||
3. 有状态序列使用 `special_handler`,但传输元数据仍放在共享清单中。
|
||||
4. 先执行离线生成和测试;真实总线验证必须另外明确授权,且分别记录 SDK 提交、CAN ACK、应用反馈和实体动作。
|
||||
@@ -1,10 +1,10 @@
|
||||
# Linux ZLG 车辆状态接入
|
||||
|
||||
当前工程可以通过 ZLG Linux VCI SDK 被动读取 USBCANFD-200U 的 CH1,并把
|
||||
车辆状态送入 `/api/hardware/vehicle-state` 和前端整车孪生。状态轮询使用独立
|
||||
`monitor-only` 桥,桥进程会拒绝所有发送请求。服务启动后由一个常驻监听线程
|
||||
持续缓存最新的已验证状态,HTTP 接口不会为每次前端请求反复开关 USB 设备。
|
||||
Linux 只读状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒内的新鲜反馈,
|
||||
当前工程可以通过 ZLG Linux VCI SDK 使用 USBCANFD-200U 的 CAN0/CAN1,并把
|
||||
车辆状态送入 `/api/hardware/vehicle-state` 和前端整车孪生。只读配置使用独立
|
||||
`monitor-only` 桥,桥进程会拒绝所有发送请求;有线控制配置则在一次请求内由
|
||||
同一桥进程共同持有 CAN0/CAN1,完成安全预检、控制和反馈后关闭。Linux 只读
|
||||
状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒内的新鲜反馈,
|
||||
断线后会自动关闭旧桥并重试连接。监听通道只接受本文列出的 12 个标准帧 ID,
|
||||
同时在 SDK 桥内保留同样的软件过滤,避免把无关总线流量转发给服务进程。
|
||||
|
||||
@@ -19,6 +19,11 @@ Linux 只读状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒
|
||||
- 数据段:2 Mbit/s,`tseg1=10, tseg2=2, sjw=2, brp=1`
|
||||
- 终端:默认不启用适配器内部 120 欧;只能按实际拓扑调整
|
||||
|
||||
有线真实执行采用一个设备 owner 同时初始化两个通道:CAN0 为最新副驾座椅
|
||||
经典 CAN 500 kbit/s,CAN1 为车辆 ISO CAN FD 500 kbit/s / 2 Mbit/s。副驾控制
|
||||
使用 `0x300/0x29E/0x3CE`,状态反馈覆盖 `0x301–0x306/0x4FA`。Linux CAN0
|
||||
不依赖旧 IAR profile、自动烧录或 T2G combined 固件检查。
|
||||
|
||||
前端车辆概况依赖 `0x342`(档位)、`0x4EB/0x07`(车速)和 `0x407`
|
||||
(门锁)。主驾座椅依赖 `0x4FA`、`0x494`。收到其他帧不能替代这些已验证
|
||||
反馈。车身设备还会按帧独立更新:`0x388` 四窗、`0x404` 空调、`0x41A`
|
||||
@@ -28,7 +33,7 @@ Linux 只读状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒
|
||||
|
||||
前端“车辆连接方式”提供两个相互独立的传输选项:
|
||||
|
||||
- 有线控制:状态和控制都走后端 ZLG CH1。
|
||||
- 有线控制:副驾座椅状态和控制走后端 ZLG CAN0,其余车辆状态和控制走 CAN1。
|
||||
- 无线控制:状态订阅 `n7/n7-headunit/can/state`,控制发布到
|
||||
`n7/n7-headunit/can/command`;默认 Broker 为 `127.0.0.1:1883`。MQTT CAN0 专用于
|
||||
副驾座椅,MQTT CAN1 专用于除副驾座椅外的车辆状态和控制。
|
||||
@@ -89,22 +94,25 @@ FD+BRS。车机按通道、控制 ID 和帧类型推断预期反馈 ID。
|
||||
|
||||
无线通道边界为:
|
||||
|
||||
- CAN0:经典 CAN 副驾座椅;直接状态接收 `0x301–0x307`、`0x4FA`。当前车端
|
||||
- CAN0:经典 CAN 副驾座椅;直接状态接收 `0x301–0x306`、`0x4FA`。当前车端
|
||||
还会以只读 `0x6A0` `SEAT_INFO(0x60)` 查询和 `0x6A2` 响应上报四轴 Hall
|
||||
位置、范围和标志。后端只配对 `C7/7C`、相同序号、`status=0` 的查询响应,
|
||||
再转换成统一的四轴 `0x302` 状态;查询帧不会被当作普通动作执行。
|
||||
无线副驾控制只允许 `0x6A0` 中固件明确提供的运行时 opcode:
|
||||
`STOP(0x63)`、`MOVE(0x64)`、`HEARTBEAT(0x65)` 和
|
||||
`DYNAMIC_START(0x66)`,并等待匹配序号/opcode 的 `0x6A2` 成功响应及新鲜
|
||||
四轴位置反馈。刷写、进入 Bootloader、测试、校准和回零 opcode 全部拒绝;
|
||||
旧 `0x29E/0x300/0x3CE` 不会在该无线 CAN0 路径发布。
|
||||
无线副驾控制直接发布与有线相同的固件原生帧:运动/停止/木马/摇篮使用
|
||||
`0x300`,通风/加热使用 `0x29E/0x3CE`;运动等待 `0x301/0x302`,动态模式
|
||||
等待 `0x301/0x306`,热舒适等待 `0x4FA`。`0x300` 只开放语义运行时子集
|
||||
`0x01/0x02/0x03/0x08/0x09/0x0B/0x0E/0x0F`,并逐字段校验版本、轴、方向、
|
||||
sequence、ParamA 和 ParamB。刷写、进入 Bootloader、测试、全量程校准、查询
|
||||
和清故障全部拒绝;`0x6A0` 不再允许作为无线控制帧发布。
|
||||
- CAN1:除副驾座椅以外的整车 CAN;状态和控制继续使用项目中已有的 CH1
|
||||
车辆白名单。CAN0 控制 ID 不能经 CAN1 发送,CAN1 控制 ID 也不能经 CAN0
|
||||
发送。
|
||||
|
||||
两个通道的协议来源严格分离:
|
||||
|
||||
- CAN0 只以 `/home/orin/project511/seat-code-file/seat-ota` 副驾固件为准;
|
||||
- CAN0 只以 `/home/orin/project511/passenger_seat_firmware/firmware` 与
|
||||
`/home/orin/project511/passenger_seat_firmware/Passenger_Seat_Controller_demo`
|
||||
中的副驾固件协议为准;
|
||||
入站状态必须是 11 位标准帧、经典 CAN、无 BRS、DLC 8。
|
||||
- CAN1 只以
|
||||
`/home/orin/project511/project-code/canfd/CAN矩阵表_filled.xlsx` 为准。
|
||||
@@ -124,8 +132,8 @@ MQTT 消息自带的 `can0Seat.parsed` 和 `can1Vehicle.parsed` 只可用于车
|
||||
|
||||
每个 MQTT 总线视图只会收到本通道反馈。发布完成仅代表 Broker 接收消息;
|
||||
座椅和整车执行器仍必须等待对应状态 ID 与目标字段匹配,才会报告动作成功。
|
||||
当前 seat-ota 固件只提供四轴位置和动态模式控制,没有副驾加热/通风运行时
|
||||
opcode;无线模式收到这两类副驾请求会在发布前明确失败,不会伪造反馈。
|
||||
副驾加热/通风使用固件已有的 `0x3CE/0x29E` 控制和 `0x4FA` 反馈;无线模式
|
||||
与有线模式共享同一套原始帧、参数白名单和反馈闭环,仅传输载体不同。
|
||||
|
||||
车端现有 `n7-can-signals/v1` 状态封装也可直接发布到
|
||||
`n7/n7-headunit/can/state`。后端优先从顶层 `signals` 读取,避免与嵌套副本重复;
|
||||
@@ -170,11 +178,11 @@ getfacl -p /dev/bus/usb/001/006
|
||||
|
||||
## 服务配置
|
||||
|
||||
将 `config/environments/linux_vehicle_state.example.env` 中的配置复制到仓库
|
||||
根目录 `.env`,并按实际 SDK 位置修改
|
||||
`COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY`。不要设置硬件批准令牌;此时
|
||||
`vehicle_can.state_configured=true`,但 `vehicle_can.configured=false`,所以只
|
||||
允许状态同步,前端真实执行保持锁定。
|
||||
只读 CH1 状态监听使用 `config/environments/linux_vehicle_state.example.env`。
|
||||
CAN0/CAN1 有线控制使用
|
||||
`config/environments/linux_wired_dual_channel.example.env`,复制到仓库根目录
|
||||
`.env` 后按实际 SDK 位置修改 `COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY`。本地有人
|
||||
值守台架可以显式关闭逐请求令牌;共享或远程环境应恢复令牌门禁。
|
||||
|
||||
启动:
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
- name: climate.set_temperature
|
||||
description: Set an absolute HVAC temperature from 18 to 32 degrees Celsius.
|
||||
target_devices: [hvac]
|
||||
target_scopes: [driver, passenger, front_row, rear_row, rear_left, rear_right, vehicle]
|
||||
target_scopes: [driver, passenger, front_row, rear, rear_row, rear_left, rear_right, vehicle]
|
||||
parameters:
|
||||
- name: temperature_c
|
||||
type: number
|
||||
|
||||
@@ -395,6 +395,9 @@ def build_function_config(
|
||||
"capability": DYNAMIC_MODE_STOP_CAPABILITY,
|
||||
"target": TARGETS[action["target"]],
|
||||
"parameters": {},
|
||||
"parse_source": str(
|
||||
action.get("parse_source", "local_rule")
|
||||
),
|
||||
"timeout_ms": 5000,
|
||||
"failure_policy": "abort",
|
||||
}
|
||||
@@ -412,6 +415,9 @@ def build_function_config(
|
||||
"capability": DYNAMIC_MODE_CAPABILITY,
|
||||
"target": TARGETS[action["target"]],
|
||||
"parameters": {"mode_id": int(action["mode_id"])},
|
||||
"parse_source": str(
|
||||
action.get("parse_source", "local_rule")
|
||||
),
|
||||
"timeout_ms": 5000,
|
||||
"failure_policy": "abort",
|
||||
}
|
||||
@@ -437,6 +443,9 @@ def build_function_config(
|
||||
"capability": MOTION_CAPABILITIES[action["axis"]],
|
||||
"target": TARGETS[action["target"]],
|
||||
"parameters": parameters,
|
||||
"parse_source": str(
|
||||
action.get("parse_source", "local_rule")
|
||||
),
|
||||
"timeout_ms": 8000,
|
||||
"failure_policy": "abort",
|
||||
}
|
||||
@@ -460,6 +469,9 @@ def build_function_config(
|
||||
"capability": CAPABILITIES[action["function"]],
|
||||
"target": TARGETS[action["target"]],
|
||||
"parameters": parameters,
|
||||
"parse_source": str(
|
||||
action.get("parse_source", "local_rule")
|
||||
),
|
||||
"timeout_ms": 3000,
|
||||
"failure_policy": "abort",
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from cognitive_agent.generated.can_semantic_protocol import (
|
||||
CAN0_SUPPORTED_CAPABILITIES,
|
||||
command_contract as shared_protocol_contract,
|
||||
)
|
||||
|
||||
|
||||
STATE_VALUES = {"on": 1, "off": 2}
|
||||
|
||||
@@ -16,7 +21,7 @@ STATE_VALUES = {"on": 1, "off": 2}
|
||||
class T2GSeatCompiler:
|
||||
"""Deterministic compiler for the HIL-verified T2G seat bench protocol."""
|
||||
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
|
||||
def __init__(self, mapping_path: Path, calibration_path: Path):
|
||||
self.mapping = yaml.safe_load(mapping_path.read_text(encoding="utf-8"))
|
||||
@@ -29,6 +34,10 @@ class T2GSeatCompiler:
|
||||
for item in self.mapping["spec"]["mappings"]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _protocol_contract(rule_id: str, key: str) -> dict[str, Any]:
|
||||
return shared_protocol_contract(rule_id, key)
|
||||
|
||||
def compile(
|
||||
self,
|
||||
function_config: dict[str, Any],
|
||||
@@ -128,6 +137,9 @@ class T2GSeatCompiler:
|
||||
"operation_ref": operation_ref,
|
||||
"bus": "seat_can",
|
||||
"message": "T2G_Seat_Thermal_Mode_3CE",
|
||||
"protocol": self._protocol_contract(
|
||||
"can0.seat.thermal", "thermal_mode"
|
||||
),
|
||||
"signals": {"Driver_Seat_Thermal_Mode": 1},
|
||||
"send_offset_ms": 0,
|
||||
}
|
||||
@@ -138,6 +150,9 @@ class T2GSeatCompiler:
|
||||
"operation_ref": operation_ref,
|
||||
"bus": "seat_can",
|
||||
"message": "T2G_Seat_Thermal_Control_29E",
|
||||
"protocol": self._protocol_contract(
|
||||
"can0.seat.thermal", "thermal_control"
|
||||
),
|
||||
"signals": {
|
||||
"Driver_Seat_Heating_Gear": int(desired["heating"]["level"]),
|
||||
"Driver_Seat_Heating_On": STATE_VALUES[desired["heating"]["state"]],
|
||||
@@ -232,6 +247,15 @@ class T2GSeatCompiler:
|
||||
),
|
||||
"bus": "seat_can",
|
||||
"message": "T2G_Seat_Move_Command_300",
|
||||
"protocol": self._protocol_contract(
|
||||
(
|
||||
"can0.seat.relative"
|
||||
if (operation.get("motion") or {}).get("mode")
|
||||
in {"relative", "timed"}
|
||||
else "can0.seat.position"
|
||||
),
|
||||
"move",
|
||||
),
|
||||
"signals": {
|
||||
"Direction": 0,
|
||||
"Motor_Selector": int(axis["motor_selector"]),
|
||||
@@ -292,6 +316,9 @@ class T2GSeatCompiler:
|
||||
"operation_ref": operation["id"],
|
||||
"bus": "seat_can",
|
||||
"message": "T2G_Seat_Move_Command_300",
|
||||
"protocol": self._protocol_contract(
|
||||
"can0.seat.dynamic", "dynamic"
|
||||
),
|
||||
"signals": {
|
||||
"Direction": 0,
|
||||
"Motor_Selector": 0,
|
||||
@@ -357,6 +384,7 @@ class T2GSeatCompiler:
|
||||
),
|
||||
"bus": "seat_can",
|
||||
"message": "T2G_Seat_Move_Command_300",
|
||||
"protocol": self._protocol_contract("can0.seat.relative", "move"),
|
||||
"signals": {
|
||||
"Direction": direction,
|
||||
"Motor_Selector": int(axis["motor_selector"]),
|
||||
@@ -426,6 +454,7 @@ class T2GSeatCompiler:
|
||||
),
|
||||
"bus": "seat_can",
|
||||
"message": "T2G_Seat_Move_Command_300",
|
||||
"protocol": self._protocol_contract("can0.seat.relative", "move"),
|
||||
"signals": {
|
||||
"Direction": direction,
|
||||
"Motor_Selector": int(axis["motor_selector"]),
|
||||
@@ -469,6 +498,10 @@ class T2GSeatCompiler:
|
||||
|
||||
def _require_supported(self, operation: dict[str, Any]) -> None:
|
||||
key = (operation["capability"], operation["target"]["zone"])
|
||||
if key[0] not in CAN0_SUPPORTED_CAPABILITIES:
|
||||
raise ValueError(
|
||||
f"shared CAN0 protocol does not support capability={key[0]}"
|
||||
)
|
||||
if key not in self.supported:
|
||||
raise ValueError(
|
||||
"T2G hardware mapping does not support "
|
||||
|
||||
@@ -9,6 +9,9 @@ from typing import Any, Callable
|
||||
|
||||
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
|
||||
from cognitive_agent.infrastructure.firmware import T2GFirmwareGuard
|
||||
from cognitive_agent.generated.can_semantic_protocol import (
|
||||
command_contract as shared_protocol_contract,
|
||||
)
|
||||
|
||||
from .executor import MOTION_CAPABILITY_TO_AXIS
|
||||
from .matrix import SeatCanMatrix
|
||||
@@ -114,6 +117,7 @@ class HardwareSeatExecutor:
|
||||
synchronize_position_before_first_motion: bool = False,
|
||||
position_sync_state: dict[str, bool] | None = None,
|
||||
require_thermal_feedback: bool = True,
|
||||
feedback_required: bool = True,
|
||||
):
|
||||
self.matrix = matrix
|
||||
self.bus_factory = bus_factory
|
||||
@@ -136,7 +140,19 @@ class HardwareSeatExecutor:
|
||||
else {"synchronized": False}
|
||||
)
|
||||
self.require_thermal_feedback = bool(require_thermal_feedback)
|
||||
# A locally attached CAN adapter can synchronously enforce the complete
|
||||
# 0x301/0x302/0x306/0x4FA feedback contract. The wireless head-unit
|
||||
# relay currently publishes CAN0 state asynchronously, so its HTTP
|
||||
# execution path must stop after the approved frame has entered MQTT.
|
||||
# Keep this opt-out explicit and default-on so wired HIL retains its
|
||||
# strict physical verification behavior.
|
||||
self.feedback_required = bool(feedback_required)
|
||||
self._current_samples: dict[int, list[dict[str, Any]]] = {}
|
||||
# Evidence for the currently executing *requested* command. Safety
|
||||
# prerequisites and best-effort STOP frames are deliberately excluded,
|
||||
# so a feedback timeout can be reported as submitted-but-unverified
|
||||
# without mistaking a prerequisite frame for the requested operation.
|
||||
self._active_command_frame: CanFrame | None = None
|
||||
|
||||
@classmethod
|
||||
def _next_sequence(cls) -> int:
|
||||
@@ -145,6 +161,49 @@ class HardwareSeatExecutor:
|
||||
cls._sequence = 1
|
||||
return cls._sequence
|
||||
|
||||
@staticmethod
|
||||
def _operation_debug_command(operation: dict[str, Any]) -> str:
|
||||
"""Return the CAN0 semantic command attached to MQTT debug metadata."""
|
||||
|
||||
capability = str(operation["capability"])
|
||||
parameters = operation.get("parameters", {})
|
||||
axes = {
|
||||
"seat.set_longitudinal_position": "horizontal",
|
||||
"seat.set_cushion_tilt": "basin",
|
||||
"seat.set_backrest_recline": "backrest",
|
||||
"seat.set_legrest_height": "legrest",
|
||||
}
|
||||
if capability in axes:
|
||||
axis = axes[capability]
|
||||
motion = operation.get("motion", {})
|
||||
relative_directions = {
|
||||
"horizontal": {"increase": "backward", "decrease": "forward"},
|
||||
"basin": {"increase": "down", "decrease": "up"},
|
||||
"backrest": {"increase": "backward", "decrease": "forward"},
|
||||
"legrest": {"increase": "up", "decrease": "down"},
|
||||
}
|
||||
direction = relative_directions[axis].get(
|
||||
str(motion.get("direction", ""))
|
||||
)
|
||||
delta = motion.get("delta_position_pct")
|
||||
if motion.get("mode") == "relative" and direction and delta is not None:
|
||||
return f"seat passenger {axis} relative {direction} {int(delta)}"
|
||||
return f"seat passenger {axis} position {parameters['position_pct']}"
|
||||
if capability in {"seat.set_ventilation", "seat.set_heating"}:
|
||||
feature = "vent" if capability.endswith("ventilation") else "heat"
|
||||
if parameters.get("state") == "off":
|
||||
return f"seat passenger {feature} off"
|
||||
level = {1: "low", 2: "medium", 3: "high"}.get(
|
||||
int(parameters.get("level", 1)), "low"
|
||||
)
|
||||
return f"seat passenger {feature} {level}"
|
||||
if capability == "seat.start_dynamic_mode":
|
||||
mode = "rocking-horse" if int(parameters["mode_id"]) == 1 else "cradle"
|
||||
return f"seat passenger dynamic {mode}"
|
||||
if capability == "seat.stop_dynamic_mode":
|
||||
return "seat passenger dynamic stop"
|
||||
return capability
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command_plan: dict[str, Any],
|
||||
@@ -169,6 +228,8 @@ class HardwareSeatExecutor:
|
||||
position_sync_deferred_to_commands = False
|
||||
position_sync_trigger = "process_first_motion"
|
||||
physical_state: dict[str, Any] | None = None
|
||||
motion_publication_start: int | None = None
|
||||
thermal_publication_start: int | None = None
|
||||
self._current_samples = {}
|
||||
|
||||
with self._adapter_lock:
|
||||
@@ -200,7 +261,22 @@ class HardwareSeatExecutor:
|
||||
else:
|
||||
bus = self.bus_factory()
|
||||
if has_motion:
|
||||
self._stop_all(bus, require_ack=True)
|
||||
motion_publication_start = len(
|
||||
getattr(bus, "published_messages", ())
|
||||
)
|
||||
set_debug_command = getattr(bus, "set_debug_command", None)
|
||||
if callable(set_debug_command):
|
||||
set_debug_command(
|
||||
"; ".join(
|
||||
self._operation_debug_command(operation)
|
||||
for operation in operations.values()
|
||||
)
|
||||
)
|
||||
# STOP_ALL is the firmware-defined sequence barrier. Keep
|
||||
# publishing it for a remote relay, but do not block the
|
||||
# requested command on a synchronous 0x301 that is delivered
|
||||
# later through the MQTT state stream.
|
||||
self._stop_all(bus, require_ack=self.feedback_required)
|
||||
motion_armed = True
|
||||
position_sync_required = (
|
||||
self.synchronize_position_before_first_motion
|
||||
@@ -231,14 +307,37 @@ class HardwareSeatExecutor:
|
||||
)
|
||||
previous_offset = 0
|
||||
for command in commands:
|
||||
self._active_command_frame = None
|
||||
offset = int(command.get("send_offset_ms", 0))
|
||||
if offset > previous_offset:
|
||||
time.sleep((offset - previous_offset) / 1000)
|
||||
previous_offset = offset
|
||||
operation_ids = command["operation_ref"].split(",")
|
||||
referenced = [operations[item] for item in operation_ids]
|
||||
publication_start = (
|
||||
thermal_publication_start
|
||||
if thermal_publication_start is not None
|
||||
else motion_publication_start
|
||||
if motion_publication_start is not None
|
||||
and command["message"] == "T2G_Seat_Move_Command_300"
|
||||
else len(getattr(bus, "published_messages", ()))
|
||||
)
|
||||
set_debug_command = getattr(bus, "set_debug_command", None)
|
||||
if callable(set_debug_command):
|
||||
set_debug_command(
|
||||
"; ".join(
|
||||
self._operation_debug_command(item)
|
||||
for item in referenced
|
||||
)
|
||||
)
|
||||
if command["message"] == "T2G_Seat_Thermal_Mode_3CE":
|
||||
frame = self._send_encoded(bus, command["message"], command["signals"])
|
||||
frame = self._send_encoded(
|
||||
bus,
|
||||
command["message"],
|
||||
command["signals"],
|
||||
self._resolved_protocol(command),
|
||||
)
|
||||
self._active_command_frame = frame
|
||||
results.append(
|
||||
{
|
||||
"ref": command["id"],
|
||||
@@ -252,19 +351,56 @@ class HardwareSeatExecutor:
|
||||
},
|
||||
}
|
||||
)
|
||||
thermal_publication_start = publication_start
|
||||
continue
|
||||
elif not self.feedback_required:
|
||||
command_results = self._submit_without_feedback(
|
||||
bus, command, referenced
|
||||
)
|
||||
elif command["message"] == "T2G_Seat_Thermal_Control_29E":
|
||||
results.extend(self._execute_thermal(bus, command, referenced))
|
||||
command_results = self._execute_thermal(
|
||||
bus, command, referenced
|
||||
)
|
||||
thermal_publication_start = None
|
||||
elif command["message"] == "T2G_Seat_Move_Command_300":
|
||||
if int(command["signals"]["Opcode"]) in {0x0E, 0x0F}:
|
||||
results.extend(
|
||||
self._execute_dynamic_mode(bus, command, referenced)
|
||||
command_results = self._execute_dynamic_mode(
|
||||
bus, command, referenced
|
||||
)
|
||||
else:
|
||||
results.extend(self._execute_motion(bus, command, referenced))
|
||||
command_results = self._execute_motion(
|
||||
bus, command, referenced
|
||||
)
|
||||
motion_publication_start = None
|
||||
else:
|
||||
raise HardwareExecutionError(
|
||||
f"hardware executor refuses unknown message {command['message']}"
|
||||
)
|
||||
mqtt_messages = list(
|
||||
getattr(bus, "published_messages", ())[publication_start:]
|
||||
)
|
||||
if mqtt_messages:
|
||||
for item in command_results:
|
||||
telemetry = item.setdefault("telemetry", {})
|
||||
telemetry["mqtt_messages"] = mqtt_messages
|
||||
if not self.feedback_required:
|
||||
delivery_status = (
|
||||
"published"
|
||||
if all(
|
||||
message.get("delivery_status") == "published"
|
||||
for message in mqtt_messages
|
||||
)
|
||||
else "queued"
|
||||
)
|
||||
telemetry["delivery_status"] = delivery_status
|
||||
item["message"] = (
|
||||
"MQTT CAN0 白名单控制帧已由 Broker 确认;"
|
||||
"副驾座椅效果以异步状态回读为准。"
|
||||
if delivery_status == "published"
|
||||
else "MQTT CAN0 白名单控制帧已进入客户端发布队列;"
|
||||
"副驾座椅效果以异步状态回读为准。"
|
||||
)
|
||||
results.extend(command_results)
|
||||
if position_sync_deferred_to_commands:
|
||||
self.position_sync_state["synchronized"] = True
|
||||
results.append(
|
||||
@@ -287,36 +423,129 @@ class HardwareSeatExecutor:
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - normalize transport and protocol failures
|
||||
if bus is not None and motion_armed:
|
||||
submitted_frame = self._active_command_frame
|
||||
feedback_timed_out = (
|
||||
submitted_frame is not None
|
||||
and isinstance(exc, HardwareExecutionError)
|
||||
and str(exc).startswith("timed out after ")
|
||||
)
|
||||
if bus is not None and motion_armed and self.feedback_required:
|
||||
self._best_effort_stop(bus)
|
||||
operation_ref = self._first_unreported_operation(operations, results)
|
||||
mqtt_messages = list(getattr(bus, "published_messages", ())) if bus else []
|
||||
delivery_status = "submitted"
|
||||
if mqtt_messages:
|
||||
delivery_status = (
|
||||
"published"
|
||||
if all(
|
||||
item.get("delivery_status") == "published"
|
||||
for item in mqtt_messages
|
||||
)
|
||||
else "queued"
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"ref": operation_ref,
|
||||
"domain": "seat",
|
||||
"status": "failed",
|
||||
"message": str(exc),
|
||||
"status": "succeeded" if feedback_timed_out else "failed",
|
||||
"message": (
|
||||
"CAN0 控制帧已提交,但反馈窗口内未完成车辆状态确认;"
|
||||
"编排提交成功,实体效果仍未验证。"
|
||||
if feedback_timed_out
|
||||
else str(exc)
|
||||
),
|
||||
"telemetry": {
|
||||
**getattr(exc, "telemetry", {}),
|
||||
"error_type": type(exc).__name__,
|
||||
"stop_all_attempted": bool(bus is not None and motion_armed),
|
||||
**(
|
||||
{
|
||||
"command_frame": submitted_frame.public(),
|
||||
"delivery_status": delivery_status,
|
||||
"verification_status": "submitted_unverified",
|
||||
"physical_effect_confirmed": False,
|
||||
"feedback_error": str(exc),
|
||||
**(
|
||||
{"mqtt_messages": mqtt_messages}
|
||||
if mqtt_messages
|
||||
else {}
|
||||
),
|
||||
}
|
||||
if feedback_timed_out
|
||||
else {
|
||||
**(
|
||||
{"command_frame": submitted_frame.public()}
|
||||
if submitted_frame is not None
|
||||
else {}
|
||||
),
|
||||
"verification_status": "failed_or_blocked",
|
||||
"physical_effect_confirmed": False,
|
||||
}
|
||||
),
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
if bus is not None:
|
||||
try:
|
||||
physical_state = self._capture_physical_state(bus, results)
|
||||
except Exception: # noqa: BLE001 - do not mask the command outcome
|
||||
if self.feedback_required:
|
||||
try:
|
||||
physical_state = self._capture_physical_state(bus, results)
|
||||
except Exception: # noqa: BLE001 - do not mask the command outcome
|
||||
physical_state = self._physical_state_from_results(results)
|
||||
else:
|
||||
# MQTT state synchronization runs independently. Do not
|
||||
# add another bounded receive wait to the control request.
|
||||
physical_state = self._physical_state_from_results(results)
|
||||
bus.close()
|
||||
|
||||
succeeded = all(item["status"] == "succeeded" for item in results)
|
||||
for item in results:
|
||||
telemetry = item.setdefault("telemetry", {})
|
||||
telemetry.setdefault(
|
||||
"verification_status",
|
||||
(
|
||||
"verified_success"
|
||||
if item.get("status") == "succeeded"
|
||||
and (
|
||||
telemetry.get("feedback_frame") is not None
|
||||
or telemetry.get("hardware_feedback") is True
|
||||
or telemetry.get("firmware_verified") is True
|
||||
)
|
||||
else "submitted_unverified"
|
||||
if item.get("status") == "succeeded"
|
||||
and telemetry.get("command_frame") is not None
|
||||
else "verified_success"
|
||||
if item.get("status") == "succeeded"
|
||||
else "failed_or_blocked"
|
||||
),
|
||||
)
|
||||
telemetry.setdefault(
|
||||
"physical_effect_confirmed",
|
||||
telemetry["verification_status"] == "verified_success",
|
||||
)
|
||||
if telemetry.get("command_frame") is not None:
|
||||
telemetry.setdefault("delivery_status", "submitted")
|
||||
verification_status = (
|
||||
"failed_or_blocked"
|
||||
if any(
|
||||
item.get("telemetry", {}).get("verification_status")
|
||||
== "failed_or_blocked"
|
||||
for item in results
|
||||
)
|
||||
else "submitted_unverified"
|
||||
if any(
|
||||
item.get("telemetry", {}).get("verification_status")
|
||||
== "submitted_unverified"
|
||||
for item in results
|
||||
)
|
||||
else "verified_success"
|
||||
)
|
||||
succeeded = verification_status != "failed_or_blocked"
|
||||
request_id = function_config["metadata"]["request_id"]
|
||||
spec: dict[str, Any] = {
|
||||
"mode": "hil",
|
||||
"status": "succeeded" if succeeded else "failed",
|
||||
"verification_status": verification_status,
|
||||
"started_at": started_at,
|
||||
"finished_at": _now(),
|
||||
"results": results,
|
||||
@@ -336,6 +565,59 @@ class HardwareSeatExecutor:
|
||||
"spec": spec,
|
||||
}
|
||||
|
||||
def _submit_without_feedback(
|
||||
self,
|
||||
bus: CanBus,
|
||||
command: dict[str, Any],
|
||||
operations: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Publish one approved CAN0 command without a synchronous state gate.
|
||||
|
||||
This mode is reserved for the wireless head-unit relay. It still uses
|
||||
the same matrix encoder and generated protocol contract as wired HIL,
|
||||
including a fresh firmware sequence for every 0x300 command. Only the
|
||||
evidence level changes: submission succeeds, while physical completion
|
||||
remains unverified until the independent MQTT state stream reports it.
|
||||
"""
|
||||
|
||||
signals = dict(command["signals"])
|
||||
if command["message"] == "T2G_Seat_Move_Command_300":
|
||||
signals["Sequence"] = self._next_sequence()
|
||||
elif command["message"] != "T2G_Seat_Thermal_Control_29E":
|
||||
raise HardwareExecutionError(
|
||||
f"wireless CAN0 executor refuses unknown message {command['message']}"
|
||||
)
|
||||
command_frame = self._send_encoded(
|
||||
bus,
|
||||
command["message"],
|
||||
signals,
|
||||
self._resolved_protocol(command),
|
||||
)
|
||||
self._active_command_frame = command_frame
|
||||
return [
|
||||
{
|
||||
"ref": operation["id"],
|
||||
"domain": "seat",
|
||||
"status": "succeeded",
|
||||
"message": (
|
||||
"CAN0 控制帧已提交到无线 MQTT;副驾座椅效果等待异步状态回读。"
|
||||
),
|
||||
"telemetry": {
|
||||
"requested_target": operation["target"]["instance"],
|
||||
"requested_parameters": operation.get("parameters", {}),
|
||||
"command_frame": command_frame.public(),
|
||||
"feedback_required": False,
|
||||
"feedback_observed": False,
|
||||
"delivery_status": "submitted",
|
||||
"verification_status": "submitted_unverified",
|
||||
"physical_effect_confirmed": False,
|
||||
"control_gate": "compiled_action_and_frame_whitelist",
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
for operation in operations
|
||||
]
|
||||
|
||||
def read_physical_state(self) -> dict[str, Any]:
|
||||
"""Read the passenger seat status without transmitting a CAN command."""
|
||||
|
||||
@@ -359,6 +641,7 @@ class HardwareSeatExecutor:
|
||||
results: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
state = self._physical_state_from_results(results)
|
||||
observed_ids: set[int] = set()
|
||||
deadline = time.monotonic() + max(
|
||||
self.thermal_timeout_ms,
|
||||
self.ack_timeout_ms,
|
||||
@@ -371,15 +654,102 @@ class HardwareSeatExecutor:
|
||||
frame = bus.receive(remaining_ms)
|
||||
if frame is None:
|
||||
break
|
||||
if len(frame.data) >= 8 and frame.arbitration_id in {
|
||||
0x301,
|
||||
0x302,
|
||||
0x303,
|
||||
0x304,
|
||||
0x305,
|
||||
0x306,
|
||||
0x4FA,
|
||||
}:
|
||||
observed_ids.add(frame.arbitration_id)
|
||||
self._observe_auxiliary_frame(frame)
|
||||
if frame.arbitration_id == 0x302 and len(frame.data) >= 8:
|
||||
if frame.arbitration_id == 0x301 and len(frame.data) >= 8:
|
||||
values = self.matrix.decode_by_frame_id(0x301, frame.data)
|
||||
state["system"] = {
|
||||
"ack": int(values["Ack"]),
|
||||
"state": int(values["System_State"]),
|
||||
"reject_reason": int(values["Reject_Reason"]),
|
||||
"last_sequence": int(values["Last_Sequence"]),
|
||||
"last_opcode": int(values["Last_Opcode"]),
|
||||
"active_mask": int(values["Active_Mask"]),
|
||||
"safety_inhibit": bool(values["Safety_Inhibit"]),
|
||||
"calibration_busy": bool(values["Calibration_Busy"]),
|
||||
"fault": bool(values["Fault_Summary"]),
|
||||
"faulted_mask": int(values["Faulted_Mask"]),
|
||||
"position_valid_mask": int(values["Position_Valid_Mask"]),
|
||||
"firmware_revision": int(values["Firmware_Revision"]),
|
||||
"feedback_frame": frame.public(),
|
||||
}
|
||||
elif frame.arbitration_id == 0x302 and len(frame.data) >= 8:
|
||||
values = self.matrix.decode_by_frame_id(0x302, frame.data)
|
||||
self._record_motion_state(state, values, frame)
|
||||
elif frame.arbitration_id == 0x303 and len(frame.data) >= 8:
|
||||
values = self.matrix.decode_by_frame_id(0x303, frame.data)
|
||||
motor = int(values["Motor_Sub_ID"])
|
||||
if motor in PHYSICAL_MOTOR_AXES:
|
||||
state.setdefault("diagnostics", {}).setdefault("motors", {})[
|
||||
PHYSICAL_MOTOR_AXES[motor]
|
||||
] = {
|
||||
"hall_count": int(values["Hall_Count"]),
|
||||
"max_hall_count": int(values["Max_Hall_Count"]),
|
||||
"hall_speed_half_pps": int(values["Hall_Speed_Half_Pps"]),
|
||||
"last_event": int(values["Last_Motor_Event"]),
|
||||
"fault_flags": int(values["Fault_Flags"]),
|
||||
"retry_count": int(values["Retry_Count"]),
|
||||
"feedback_frame": frame.public(),
|
||||
}
|
||||
elif frame.arbitration_id == 0x304 and len(frame.data) >= 8:
|
||||
values = self.matrix.decode_by_frame_id(0x304, frame.data)
|
||||
motor = int(values["Motor_Sub_ID"])
|
||||
if motor in PHYSICAL_MOTOR_AXES:
|
||||
motor_state = state.setdefault("diagnostics", {}).setdefault(
|
||||
"motors", {}
|
||||
).setdefault(PHYSICAL_MOTOR_AXES[motor], {})
|
||||
motor_state["current"] = {
|
||||
"sample_valid": bool(values["Current_Sample_Valid"]),
|
||||
"baseline_valid": bool(values["Current_Baseline_Valid"]),
|
||||
"high_current": bool(values["High_Current"]),
|
||||
"hall_activity_low": bool(values["Hall_Activity_Low"]),
|
||||
"calibrating": bool(values["Current_Calibrating"]),
|
||||
"raw": int(values["Current_Raw"]),
|
||||
"baseline_raw": int(values["Current_Baseline_Raw"]),
|
||||
"stall_delta_threshold_raw": int(
|
||||
values["Stall_Current_Delta_Threshold_Raw"]
|
||||
),
|
||||
"feedback_frame": frame.public(),
|
||||
}
|
||||
elif frame.arbitration_id == 0x305 and len(frame.data) >= 8:
|
||||
values = self.matrix.decode_by_frame_id(0x305, frame.data)
|
||||
state.setdefault("diagnostics", {})["thermal"] = {
|
||||
"fan_sample_valid": bool(values["Fan_Current_Sample_Valid"]),
|
||||
"heater_sample_valid": bool(
|
||||
values["Heater_Current_Sample_Valid"]
|
||||
),
|
||||
"fan_running": bool(values["Fan_Running"]),
|
||||
"heater_level": int(values["Heater_Level"]),
|
||||
"fan_current_raw": int(values["Fan_Current_Raw"]),
|
||||
"heater_current_raw": int(values["Heater_Current_Raw"]),
|
||||
"fan_adc_error_count": int(values["Fan_Adc_Error_Count"]),
|
||||
"heater_adc_error_count": int(
|
||||
values["Heater_Adc_Error_Count"]
|
||||
),
|
||||
"feedback_frame": frame.public(),
|
||||
}
|
||||
elif frame.arbitration_id == 0x4FA and len(frame.data) >= 8:
|
||||
values = self.matrix.decode_by_frame_id(0x4FA, frame.data)
|
||||
self._record_thermal_state(state, values, frame)
|
||||
elif frame.arbitration_id == 0x306 and len(frame.data) >= 8:
|
||||
values = self.matrix.decode_by_frame_id(0x306, frame.data)
|
||||
self._record_dynamic_state(state, values, frame)
|
||||
state["captured_at"] = _now()
|
||||
state["source"] = "physical_can"
|
||||
state["channel"] = 0
|
||||
state["protocol_profile"] = "passenger-seat-native-can0-v1"
|
||||
state["observed_frame_ids"] = [
|
||||
f"0x{frame_id:03X}" for frame_id in sorted(observed_ids)
|
||||
]
|
||||
state["target"] = {
|
||||
"device": "seat",
|
||||
"zone": "front_right",
|
||||
@@ -461,6 +831,30 @@ class HardwareSeatExecutor:
|
||||
self._set_thermal_feature(state, feature, raw)
|
||||
state[feature]["feedback_frame"] = frame.public()
|
||||
|
||||
@staticmethod
|
||||
def _record_dynamic_state(
|
||||
state: dict[str, Any],
|
||||
values: dict[str, int],
|
||||
frame: CanFrame,
|
||||
) -> None:
|
||||
mode_id = int(values["Dynamic_Mode_ID"])
|
||||
dynamic_state = int(values["Dynamic_State"])
|
||||
names = {1: "木马模式", 2: "摇篮模式"}
|
||||
states = {0: "idle", 1: "entry", 2: "running", 3: "fault"}
|
||||
state["dynamic_mode"] = {
|
||||
"mode_id": mode_id,
|
||||
"name": names.get(mode_id, ""),
|
||||
"state": states.get(dynamic_state, "unknown"),
|
||||
"phase": int(values["Dynamic_Phase"]),
|
||||
"active_mask": int(values["Dynamic_Active_Mask"]),
|
||||
"fault_mask": int(values["Dynamic_Fault_Mask"]),
|
||||
"cycle_index": int(values["Dynamic_Cycle_Index"]),
|
||||
"phase_elapsed_ms": int(values["Dynamic_Phase_Elapsed_Ms"]),
|
||||
"repeat": "bounded_runtime" if dynamic_state in {1, 2} else "none",
|
||||
"max_runtime_ms": 600_000,
|
||||
"feedback_frame": frame.public(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _set_thermal_feature(
|
||||
state: dict[str, Any], feature: str, raw: int
|
||||
@@ -505,10 +899,66 @@ class HardwareSeatExecutor:
|
||||
return next((item for item in operations if item not in reported), next(iter(operations)))
|
||||
|
||||
def _send_encoded(
|
||||
self, bus: CanBus, message: str, signals: dict[str, int]
|
||||
self,
|
||||
bus: CanBus,
|
||||
message: str,
|
||||
signals: dict[str, int],
|
||||
protocol: dict[str, Any] | None = None,
|
||||
) -> CanFrame:
|
||||
encoded = self.matrix.encode(message, signals)
|
||||
return bus.send(int(encoded["frame_id"], 16), bytes.fromhex(encoded["data_hex"]))
|
||||
frame_id = int(encoded["frame_id"], 16)
|
||||
payload = bytes.fromhex(encoded["data_hex"])
|
||||
if protocol is not None:
|
||||
expected_id = int(str(protocol["frame_id"]), 16)
|
||||
if frame_id != expected_id:
|
||||
raise HardwareExecutionError(
|
||||
f"shared protocol expects 0x{expected_id:03X}, matrix encoded 0x{frame_id:03X}"
|
||||
)
|
||||
if protocol["format"] != "classic_can" or protocol["brs"]:
|
||||
raise HardwareExecutionError(
|
||||
"CAN0 shared protocol requires classic CAN without BRS"
|
||||
)
|
||||
if len(payload) != int(protocol["dlc"]):
|
||||
raise HardwareExecutionError(
|
||||
"CAN0 encoded payload length disagrees with shared protocol"
|
||||
)
|
||||
return bus.send(frame_id, payload)
|
||||
|
||||
@staticmethod
|
||||
def _resolved_protocol(command: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the generated contract, including for legacy in-memory plans."""
|
||||
|
||||
protocol = command.get("protocol")
|
||||
if isinstance(protocol, dict):
|
||||
return protocol
|
||||
message = str(command.get("message", ""))
|
||||
if message == "T2G_Seat_Thermal_Mode_3CE":
|
||||
rule_id, key = "can0.seat.thermal", "thermal_mode"
|
||||
elif message == "T2G_Seat_Thermal_Control_29E":
|
||||
rule_id, key = "can0.seat.thermal", "thermal_control"
|
||||
elif message == "T2G_Seat_Move_Command_300":
|
||||
opcode = int(command.get("signals", {}).get("Opcode", -1))
|
||||
if opcode in {0x0E, 0x0F}:
|
||||
rule_id, key = "can0.seat.dynamic", "dynamic"
|
||||
elif opcode == 0x03:
|
||||
rule_id, key = "can0.seat.relative", "move"
|
||||
else:
|
||||
rule_id, key = "can0.seat.position", "move"
|
||||
else:
|
||||
raise HardwareExecutionError(
|
||||
f"cannot resolve generated CAN0 protocol for message {message}"
|
||||
)
|
||||
return shared_protocol_contract(rule_id, key)
|
||||
|
||||
@classmethod
|
||||
def _protocol_feedback_ids(cls, command: dict[str, Any]) -> tuple[int, ...]:
|
||||
protocol = cls._resolved_protocol(command)
|
||||
values = protocol.get("feedback_ids")
|
||||
if not isinstance(values, list) or not values:
|
||||
raise HardwareExecutionError(
|
||||
"compiled CAN0 command has no generated feedback IDs"
|
||||
)
|
||||
return tuple(int(str(value), 16) for value in values)
|
||||
|
||||
def _receive_until(
|
||||
self,
|
||||
@@ -608,7 +1058,13 @@ class HardwareSeatExecutor:
|
||||
}
|
||||
|
||||
def _wait_ack(
|
||||
self, bus: CanBus, sequence: int, opcode: int, *, expected_ack: int = 1
|
||||
self,
|
||||
bus: CanBus,
|
||||
sequence: int,
|
||||
opcode: int,
|
||||
*,
|
||||
expected_ack: int = 1,
|
||||
arbitration_id: int = 0x301,
|
||||
) -> tuple[dict[str, int], CanFrame]:
|
||||
def matches(values: dict[str, int], _: CanFrame) -> bool:
|
||||
if values["Last_Sequence"] != sequence or values["Last_Opcode"] != opcode:
|
||||
@@ -628,7 +1084,9 @@ class HardwareSeatExecutor:
|
||||
)
|
||||
return True
|
||||
|
||||
return self._receive_until(bus, 0x301, self.ack_timeout_ms, matches)
|
||||
return self._receive_until(
|
||||
bus, arbitration_id, self.ack_timeout_ms, matches
|
||||
)
|
||||
|
||||
def _stop_all(self, bus: CanBus, *, require_ack: bool) -> tuple[CanFrame, CanFrame | None]:
|
||||
sequence = self._next_sequence()
|
||||
@@ -659,7 +1117,14 @@ class HardwareSeatExecutor:
|
||||
command: dict[str, Any],
|
||||
operations: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
command_frame = self._send_encoded(bus, command["message"], command["signals"])
|
||||
command_frame = self._send_encoded(
|
||||
bus,
|
||||
command["message"],
|
||||
command["signals"],
|
||||
self._resolved_protocol(command),
|
||||
)
|
||||
self._active_command_frame = command_frame
|
||||
(thermal_feedback_id,) = self._protocol_feedback_ids(command)
|
||||
expected: dict[str, int] = {}
|
||||
for operation in operations:
|
||||
feature = operation["capability"].removeprefix("seat.set_")
|
||||
@@ -676,7 +1141,7 @@ class HardwareSeatExecutor:
|
||||
return all(values[name] == value for name, value in expected.items())
|
||||
|
||||
values, feedback = self._receive_until(
|
||||
bus, 0x4FA, self.thermal_timeout_ms, matches
|
||||
bus, thermal_feedback_id, self.thermal_timeout_ms, matches
|
||||
)
|
||||
output = []
|
||||
for operation in operations:
|
||||
@@ -731,9 +1196,16 @@ class HardwareSeatExecutor:
|
||||
signals = dict(command["signals"])
|
||||
sequence = self._next_sequence()
|
||||
signals["Sequence"] = sequence
|
||||
command_frame = self._send_encoded(bus, command["message"], signals)
|
||||
ack_feedback_id, dynamic_feedback_id = self._protocol_feedback_ids(command)
|
||||
command_frame = self._send_encoded(
|
||||
bus, command["message"], signals, self._resolved_protocol(command)
|
||||
)
|
||||
self._active_command_frame = command_frame
|
||||
ack_values, ack_frame = self._wait_ack(
|
||||
bus, sequence, 0x0F if stopping else 0x0E
|
||||
bus,
|
||||
sequence,
|
||||
0x0F if stopping else 0x0E,
|
||||
arbitration_id=ack_feedback_id,
|
||||
)
|
||||
|
||||
completion = command["completion"]
|
||||
@@ -753,7 +1225,7 @@ class HardwareSeatExecutor:
|
||||
|
||||
status_values, status_frame = self._receive_until(
|
||||
bus,
|
||||
0x306,
|
||||
dynamic_feedback_id,
|
||||
int(completion["timeout_ms"]),
|
||||
matches,
|
||||
)
|
||||
@@ -889,8 +1361,14 @@ class HardwareSeatExecutor:
|
||||
sequence = self._next_sequence()
|
||||
signals["Sequence"] = sequence
|
||||
self._reset_current_samples(motor)
|
||||
command_frame = self._send_encoded(bus, command["message"], signals)
|
||||
ack_values, ack_frame = self._wait_ack(bus, sequence, opcode)
|
||||
ack_feedback_id, motor_feedback_id = self._protocol_feedback_ids(command)
|
||||
command_frame = self._send_encoded(
|
||||
bus, command["message"], signals, self._resolved_protocol(command)
|
||||
)
|
||||
self._active_command_frame = command_frame
|
||||
ack_values, ack_frame = self._wait_ack(
|
||||
bus, sequence, opcode, arbitration_id=ack_feedback_id
|
||||
)
|
||||
|
||||
def complete(values: dict[str, int], _: CanFrame) -> bool:
|
||||
if values["Motor_Sub_ID"] != motor or values["Motor_Sequence"] != sequence:
|
||||
@@ -914,7 +1392,7 @@ class HardwareSeatExecutor:
|
||||
|
||||
values, feedback = self._receive_until(
|
||||
bus,
|
||||
0x302,
|
||||
motor_feedback_id,
|
||||
int(command["completion"]["timeout_ms"]),
|
||||
complete,
|
||||
)
|
||||
@@ -1362,8 +1840,14 @@ class HardwareSeatExecutor:
|
||||
signals["Sequence"] = sequence
|
||||
self._reset_current_samples(motor)
|
||||
motion_started = time.perf_counter()
|
||||
command_frame = self._send_encoded(bus, command["message"], signals)
|
||||
ack_values, ack_frame = self._wait_ack(bus, sequence, 3)
|
||||
ack_feedback_id, motor_feedback_id = self._protocol_feedback_ids(command)
|
||||
command_frame = self._send_encoded(
|
||||
bus, command["message"], signals, self._resolved_protocol(command)
|
||||
)
|
||||
self._active_command_frame = command_frame
|
||||
ack_values, ack_frame = self._wait_ack(
|
||||
bus, sequence, 3, arbitration_id=ack_feedback_id
|
||||
)
|
||||
|
||||
def terminal(values: dict[str, int], _: CanFrame) -> bool:
|
||||
if values["Motor_Sub_ID"] != motor or values["Motor_Sequence"] != sequence:
|
||||
@@ -1374,7 +1858,7 @@ class HardwareSeatExecutor:
|
||||
|
||||
values, feedback = self._receive_until(
|
||||
bus,
|
||||
0x302,
|
||||
motor_feedback_id,
|
||||
int(command["completion"]["timeout_ms"]),
|
||||
terminal,
|
||||
)
|
||||
|
||||
@@ -20,28 +20,55 @@ STATE_PATTERN = re.compile(
|
||||
)
|
||||
LEVEL_PATTERN = re.compile(r"(最高|最低|[1-5一二三四五高中低])\s*档")
|
||||
POSITION_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:位置)?(?:调到|移动到|移到|设为|设置为|调至|移动至|到|为)?"
|
||||
r"{alias}(?:位置)?(?:调到|调至|移动到|移动至|移到|移至|"
|
||||
r"转动到|转动至|旋转到|旋转至|调节到|调节至|调整到|调整至|"
|
||||
r"设为|设置为|设置到|设置至|设置|设|到|为)?"
|
||||
r"(?P<position>[+-]?\d{{1,3}}(?:\.\d+)?)%"
|
||||
)
|
||||
ANGLE_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:夹角|角度)?(?:调到|放倒到|抬到|移动到|移到|设为|设置为|"
|
||||
r"调至|到|为)?(?P<angle>\d{{2,3}})(?:度|°)"
|
||||
r"{alias}(?:夹角|角度)?(?:调到|调至|放倒到|抬到|移动到|移动至|移到|移至|"
|
||||
r"转动到|转动至|旋转到|旋转至|调节到|调节至|调整到|调整至|"
|
||||
r"设为|设置为|设置到|设置至|设置|设|到|为)?"
|
||||
r"(?P<angle>\d{{2,3}})(?:度|°)"
|
||||
)
|
||||
RELATIVE_ANGLE_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:再|继续|接着|然后)?(?P<direction>向前|往前|朝前|前倾|向后|往后|朝后|后仰|"
|
||||
r"{alias}(?:再|继续|接着|然后)?"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
|
||||
r"(?:再|继续|接着|然后)?"
|
||||
r"(?P<direction>向前|往前|朝前|前倾|向后|往后|朝后|后仰|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
|
||||
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})(?:度|°)"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
|
||||
r"(?P<delta>\d{{1,3}})(?:度|°)"
|
||||
)
|
||||
RELATIVE_PERCENT_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:再|继续|接着|然后)?(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
|
||||
r"{alias}(?:再|继续|接着|然后)?"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
|
||||
r"(?:再|继续|接着|然后)?"
|
||||
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
|
||||
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})%"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
|
||||
r"(?P<delta>\d{{1,3}})%"
|
||||
)
|
||||
DIRECTIONAL_ABSOLUTE_PERCENT_PATTERN = re.compile(
|
||||
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)*"
|
||||
r"(?:到|至|为)"
|
||||
r"(?P<position>[+-]?\d{1,3}(?:\.\d+)?)%"
|
||||
)
|
||||
DIRECTIONAL_RELATIVE_PERCENT_PATTERN = re.compile(
|
||||
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
|
||||
r"(?P<delta>\d{1,3})%"
|
||||
)
|
||||
TIMED_MOTION_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:持续|保持)?(?:再|继续|接着|然后)?"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|倾斜|抬|升|降)?"
|
||||
r"(?:再|继续|接着|然后)?"
|
||||
r"(?P<direction>向前|往前|朝前|前移|前倾|向后|往后|朝后|后移|后仰|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
|
||||
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降|运行|运动|持续|保持|时长)*"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|倾斜|抬|升|降|运行|运动|持续|保持|时长)*"
|
||||
r"(?P<duration>\d+(?:\.\d+)?)(?P<unit>毫秒|ms|秒|s|分钟|分|min)"
|
||||
)
|
||||
ZERO_POSITION_PATTERN = re.compile(r"归零|回零|复位|零位")
|
||||
@@ -52,6 +79,15 @@ STRONG_PATTERN = re.compile(r"很|太|非常|特别|难受")
|
||||
AIRFLOW_CAUSE_PATTERN = re.compile(r"吹|风太大|风吹")
|
||||
FIRST_PERSON_PATTERN = re.compile(r"我|自己")
|
||||
VEHICLE_SCOPE_PATTERN = re.compile(r"车上|车内|车里|全车|整车|整个车")
|
||||
NON_SEAT_DEVICE_PATTERN = re.compile(
|
||||
r"车窗|车门|门锁|空调|温度|风量|风速|出风模式|"
|
||||
r"内循环|外循环|循环模式|风口|遮阳帘|天幕|阅读灯|"
|
||||
r"室内灯|车内灯|氛围灯"
|
||||
)
|
||||
SEAT_CONTROL_PATTERN = re.compile(
|
||||
r"座椅|坐椅|座位|靠背|椅背|坐盆|坐垫|滑轨|腿托|脚托|四轴|"
|
||||
r"通风|加热|按摩|记忆|预设"
|
||||
)
|
||||
PASSENGER_ONLY_QUESTION = "当前所有座椅能力仅控制副驾,不支持主驾座椅目标。"
|
||||
UNSUPPORTED_SEAT_SCOPE_PATTERN = re.compile(
|
||||
r"后排|第二排|二排|第三排|三排|左前座椅|左前座|左前|"
|
||||
@@ -116,8 +152,15 @@ LEVELS = {
|
||||
MOTION_AXIS_ALIASES = {
|
||||
"longitudinal": ("水平", "前后", "纵向", "滑轨"),
|
||||
# Internal capability name is retained for compatibility. On the
|
||||
# seat-ota CAN0 firmware this board axis is the physical seat-height axis.
|
||||
"cushion_tilt": ("座椅高度", "高度", "坐垫倾角", "坐盆", "坐垫"),
|
||||
# Native CAN0 firmware axis 1 is the physical seat-height axis.
|
||||
"cushion_tilt": (
|
||||
"座椅高度",
|
||||
"高度",
|
||||
"坐垫倾角",
|
||||
"坐盆倾角",
|
||||
"坐盆",
|
||||
"坐垫",
|
||||
),
|
||||
"backrest_recline": ("靠背", "椅背"),
|
||||
"legrest_height": ("腿部支撑", "小腿托", "腿托", "脚托"),
|
||||
}
|
||||
@@ -213,6 +256,7 @@ def _normalize_text(value: str) -> str:
|
||||
text = re.sub(r"座椅(?:吹风|送风)", "座椅通风", text)
|
||||
text = re.sub(r"座椅(?:取暖|加温)", "座椅加热", text)
|
||||
text = text.replace("座椅暖一下", "座椅加热打开")
|
||||
text = text.replace("推托", "腿托")
|
||||
|
||||
def replace_percent(match: re.Match[str]) -> str:
|
||||
number = _chinese_integer(match.group(1))
|
||||
@@ -242,6 +286,74 @@ def _relative_direction(axis: str, token: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _directional_motion_axis(clause: str, direction: str) -> str | None:
|
||||
"""Resolve an omitted passenger-seat axis from the movement direction."""
|
||||
|
||||
explicit_axes = [
|
||||
axis
|
||||
for axis, aliases in MOTION_AXIS_ALIASES.items()
|
||||
if any(alias in clause for alias in aliases)
|
||||
]
|
||||
if len(explicit_axes) == 1:
|
||||
return explicit_axes[0]
|
||||
if explicit_axes:
|
||||
return None
|
||||
if TARGET_PATTERN.search(clause) is None and not re.search(r"座椅|座位", clause):
|
||||
return None
|
||||
if direction in {
|
||||
"向前",
|
||||
"往前",
|
||||
"朝前",
|
||||
"前移",
|
||||
"向后",
|
||||
"往后",
|
||||
"朝后",
|
||||
"后移",
|
||||
}:
|
||||
return "longitudinal"
|
||||
if direction in {
|
||||
"向上",
|
||||
"往上",
|
||||
"抬高",
|
||||
"抬起",
|
||||
"升高",
|
||||
"向下",
|
||||
"往下",
|
||||
"降低",
|
||||
"降下",
|
||||
}:
|
||||
return "cushion_tilt"
|
||||
return None
|
||||
|
||||
|
||||
def _absolute_percent_motion_action(
|
||||
axis: str, requested_position_value: float
|
||||
) -> dict[str, Any]:
|
||||
requested_position_pct = _round_half_away_from_zero(requested_position_value)
|
||||
position_pct = max(0, min(100, requested_position_pct))
|
||||
action: dict[str, Any] = {
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": axis,
|
||||
"position_pct": position_pct,
|
||||
}
|
||||
notices: list[str] = []
|
||||
if requested_position_value != requested_position_pct:
|
||||
notices.append(
|
||||
f"{MOTION_AXIS_LABELS[axis]}请求位置"
|
||||
f"{_format_numeric_request(requested_position_value)}%;"
|
||||
f"已取整为{requested_position_pct}%。"
|
||||
)
|
||||
if requested_position_pct != position_pct:
|
||||
notices.append(
|
||||
f"取整后超过0到100%行程;已采用{position_pct}%的备用目标"
|
||||
"并移动到对应机械末端。"
|
||||
)
|
||||
if notices:
|
||||
action["user_notice"] = " ".join(notices)
|
||||
return action
|
||||
|
||||
|
||||
def _context_motion_state(
|
||||
context: dict[str, Any] | None, axis: str
|
||||
) -> dict[str, Any] | None:
|
||||
@@ -799,6 +911,16 @@ def parse_local_intent(
|
||||
"""Parse common Chinese seat commands without making hardware assumptions."""
|
||||
|
||||
text = _normalize_text(utterance)
|
||||
if NON_SEAT_DEVICE_PATTERN.search(text) and not SEAT_CONTROL_PATTERN.search(text):
|
||||
# This parser owns CAN0 passenger-seat semantics only. Occupant words
|
||||
# such as “主驾/副驾” in a window or climate clause are spatial
|
||||
# qualifiers for that device and must not create a seat ambiguity.
|
||||
return {
|
||||
"actions": [],
|
||||
"ambiguities": [],
|
||||
"decision_summary": [],
|
||||
"confidence": 0.0,
|
||||
}
|
||||
clauses = [part for part in re.split(r"[,,。;;!!??]", text) if part]
|
||||
actions: list[dict[str, Any]] = []
|
||||
motion_actions: list[dict[str, Any]] = []
|
||||
@@ -963,6 +1085,32 @@ def parse_local_intent(
|
||||
if shared_position is not None:
|
||||
continue
|
||||
|
||||
directional_absolute_axes: set[str] = set()
|
||||
directional_absolute_match = DIRECTIONAL_ABSOLUTE_PERCENT_PATTERN.search(clause)
|
||||
if directional_absolute_match is not None:
|
||||
axis = _directional_motion_axis(
|
||||
clause, directional_absolute_match.group("direction")
|
||||
)
|
||||
if axis is not None:
|
||||
directional_absolute_axes.add(axis)
|
||||
target_zh = (
|
||||
_nearest_preceding_target(
|
||||
clause, directional_absolute_match.start()
|
||||
)
|
||||
or "副驾"
|
||||
)
|
||||
if "driver" in TARGETS[target_zh]:
|
||||
ambiguities.append(
|
||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||
)
|
||||
else:
|
||||
motion_actions.append(
|
||||
_absolute_percent_motion_action(
|
||||
axis,
|
||||
float(directional_absolute_match.group("position")),
|
||||
)
|
||||
)
|
||||
|
||||
timed_axes: set[str] = set()
|
||||
timed_aliases = {
|
||||
**MOTION_AXIS_ALIASES,
|
||||
@@ -1003,7 +1151,7 @@ def parse_local_intent(
|
||||
|
||||
relative_axes: set[str] = set()
|
||||
for axis, aliases in MOTION_AXIS_ALIASES.items():
|
||||
if axis in timed_axes:
|
||||
if axis in timed_axes or axis in directional_absolute_axes:
|
||||
continue
|
||||
for alias in aliases:
|
||||
angle_match = re.search(
|
||||
@@ -1043,6 +1191,46 @@ def parse_local_intent(
|
||||
motion_actions.append(action)
|
||||
break
|
||||
|
||||
directional_relative_match = DIRECTIONAL_RELATIVE_PERCENT_PATTERN.search(clause)
|
||||
if (
|
||||
directional_relative_match is not None
|
||||
and not relative_axes
|
||||
and not directional_absolute_axes
|
||||
):
|
||||
axis = _directional_motion_axis(
|
||||
clause, directional_relative_match.group("direction")
|
||||
)
|
||||
if axis is not None and axis not in timed_axes:
|
||||
relative_axes.add(axis)
|
||||
target_zh = (
|
||||
_nearest_preceding_target(
|
||||
clause, directional_relative_match.start()
|
||||
)
|
||||
or "副驾"
|
||||
)
|
||||
if "driver" in TARGETS[target_zh]:
|
||||
ambiguities.append(
|
||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||
)
|
||||
else:
|
||||
action, question = _relative_motion_action(
|
||||
axis,
|
||||
directional_relative_match.group("direction"),
|
||||
int(directional_relative_match.group("delta")),
|
||||
"percent",
|
||||
context,
|
||||
)
|
||||
if action is None:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"passenger.position.{axis}",
|
||||
"question": question
|
||||
or "无法计算座椅相对移动目标。",
|
||||
}
|
||||
)
|
||||
else:
|
||||
motion_actions.append(action)
|
||||
|
||||
endpoint_actions: dict[str, int] = {}
|
||||
has_specific_non_longitudinal_axis = any(
|
||||
any(alias in clause for alias in MOTION_AXIS_ALIASES[axis])
|
||||
@@ -1059,7 +1247,7 @@ def parse_local_intent(
|
||||
endpoint_actions["longitudinal"] = 0
|
||||
elif re.search(r"最后面|最后|向后移到底|移到最后", clause):
|
||||
endpoint_actions["longitudinal"] = 100
|
||||
cushion_alias = r"(?:座椅高度|高度|坐垫倾角|坐盆|坐垫)"
|
||||
cushion_alias = r"(?:座椅高度|高度|坐垫倾角|坐盆倾角|坐盆|坐垫)"
|
||||
if re.search(
|
||||
cushion_alias + r"(?:抬到最上方|调到最上方|移到最上方|最高|抬到头|抬到底)",
|
||||
clause,
|
||||
@@ -1109,7 +1297,12 @@ def parse_local_intent(
|
||||
)
|
||||
|
||||
for axis, aliases in MOTION_AXIS_ALIASES.items():
|
||||
if axis in endpoint_actions or axis in relative_axes or axis in timed_axes:
|
||||
if (
|
||||
axis in endpoint_actions
|
||||
or axis in relative_axes
|
||||
or axis in timed_axes
|
||||
or axis in directional_absolute_axes
|
||||
):
|
||||
continue
|
||||
for alias in aliases:
|
||||
position_match = re.search(
|
||||
|
||||
@@ -311,7 +311,7 @@ class SeatServiceIntegrationTests(unittest.TestCase):
|
||||
]
|
||||
self.assertEqual(
|
||||
commands,
|
||||
["seat driver memory recall 3", "ac temp driver 25", "ac fan 1"],
|
||||
["seat driver position 3", "ac temp driver 25", "ac fan 1"],
|
||||
)
|
||||
|
||||
def test_model_climate_request_completes_domain_loop(self) -> None:
|
||||
|
||||
@@ -67,6 +67,24 @@ class LocalIntentParserTests(unittest.TestCase):
|
||||
self.assertEqual(result["actions"][0]["target"], "passenger")
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
|
||||
def test_non_seat_spatial_qualifiers_do_not_enter_can0_seat_rules(self) -> None:
|
||||
utterances = (
|
||||
"打开主驾车窗",
|
||||
"打开副驾车窗",
|
||||
"把主驾和副驾温度都设为24度",
|
||||
"风量调到2档",
|
||||
"设置主驾出风模式为吹脸",
|
||||
"把副驾车窗移动到30%",
|
||||
"将主驾空调温度设置为24度",
|
||||
"把遮阳帘转动到20%",
|
||||
)
|
||||
for utterance in utterances:
|
||||
with self.subTest(utterance=utterance):
|
||||
result = parse_local_intent(utterance)
|
||||
|
||||
self.assertEqual(result["actions"], [])
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
|
||||
def test_parses_four_axis_zero_for_passenger(self) -> None:
|
||||
result = parse_local_intent("把水平、坐盆、靠背、腿托四轴归零")
|
||||
|
||||
@@ -144,6 +162,128 @@ class LocalIntentParserTests(unittest.TestCase):
|
||||
self.assertEqual(result["actions"][0]["position_pct"], 65)
|
||||
self.assertEqual(result["actions"][0]["motion_intent"]["direction"], "decrease")
|
||||
|
||||
def test_parses_passenger_directional_relative_percentages(self) -> None:
|
||||
context = self.motion_context()
|
||||
for state in context["devices"]["passenger"]["position"].values():
|
||||
state["position_pct"] = 50
|
||||
cases = (
|
||||
("副驾座椅往上移动10%", "cushion_tilt", "decrease", 10),
|
||||
("副驾座椅往上移动20%", "cushion_tilt", "decrease", 20),
|
||||
("副驾往下移动10%", "cushion_tilt", "increase", 10),
|
||||
("副驾往下移动20%", "cushion_tilt", "increase", 20),
|
||||
("副驾座椅往前移动10%", "longitudinal", "decrease", 10),
|
||||
("副驾座椅往前移动20%", "longitudinal", "decrease", 20),
|
||||
("副驾往后移动10%", "longitudinal", "increase", 10),
|
||||
("副驾往后移动20%", "longitudinal", "increase", 20),
|
||||
("副驾座椅靠背往前移动10%", "backrest_recline", "decrease", 10),
|
||||
("副驾座椅靠背往前移动30%", "backrest_recline", "decrease", 30),
|
||||
("副驾靠背往后移动10%", "backrest_recline", "increase", 10),
|
||||
("副驾靠背往后移动30%", "backrest_recline", "increase", 30),
|
||||
("副驾推托往上移动20%", "legrest_height", "increase", 20),
|
||||
("副驾推托往上移动40%", "legrest_height", "increase", 40),
|
||||
("副驾推托往下移动20%", "legrest_height", "decrease", 20),
|
||||
("副驾推托往下移动40%", "legrest_height", "decrease", 40),
|
||||
)
|
||||
|
||||
for utterance, axis, direction, delta in cases:
|
||||
with self.subTest(utterance=utterance):
|
||||
result = parse_local_intent(utterance, context)
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(len(result["actions"]), 1)
|
||||
action = result["actions"][0]
|
||||
self.assertEqual(action["axis"], axis)
|
||||
self.assertEqual(action["motion_intent"]["mode"], "relative")
|
||||
self.assertEqual(action["motion_intent"]["direction"], direction)
|
||||
self.assertEqual(action["motion_intent"]["delta_position_pct"], delta)
|
||||
|
||||
def test_motion_verbs_are_weak_signals_for_relative_seat_controls(self) -> None:
|
||||
context = self.motion_context()
|
||||
for state in context["devices"]["passenger"]["position"].values():
|
||||
state["position_pct"] = 50
|
||||
cases = (
|
||||
("副驾座椅往前移动10%", "longitudinal", "decrease", 10),
|
||||
("副驾靠背往后转动20%", "backrest_recline", "increase", 20),
|
||||
("副驾腿托往上调节30%", "legrest_height", "increase", 30),
|
||||
("副驾座椅高度往下设置10%", "cushion_tilt", "increase", 10),
|
||||
("副驾靠背转动往前20%", "backrest_recline", "decrease", 20),
|
||||
("副驾腿托设置往下30%", "legrest_height", "decrease", 30),
|
||||
("副驾靠背往后旋转10度", "backrest_recline", "increase", 10),
|
||||
)
|
||||
|
||||
for utterance, axis, direction, amount in cases:
|
||||
with self.subTest(utterance=utterance):
|
||||
result = parse_local_intent(utterance, context)
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(len(result["actions"]), 1)
|
||||
action = result["actions"][0]
|
||||
self.assertEqual(action["axis"], axis)
|
||||
self.assertEqual(action["motion_intent"]["mode"], "relative")
|
||||
self.assertEqual(action["motion_intent"]["direction"], direction)
|
||||
motion = action["motion_intent"]
|
||||
requested = motion.get("requested_delta_angle_deg")
|
||||
if requested is None:
|
||||
requested = motion.get("requested_delta_position_pct")
|
||||
if requested is None:
|
||||
requested = motion.get(
|
||||
"delta_angle_deg", motion.get("delta_position_pct")
|
||||
)
|
||||
self.assertEqual(requested, amount)
|
||||
|
||||
def test_directional_move_to_percentage_is_absolute_not_relative(self) -> None:
|
||||
cases = (
|
||||
("副驾座椅往上移动到10%", "cushion_tilt", 10),
|
||||
("副驾座椅往上移动到20%", "cushion_tilt", 20),
|
||||
("副驾往下移动到10%", "cushion_tilt", 10),
|
||||
("副驾往下移动到20%", "cushion_tilt", 20),
|
||||
("副驾座椅往前移动到10%", "longitudinal", 10),
|
||||
("副驾座椅往前移动到20%", "longitudinal", 20),
|
||||
("副驾往后移动到10%", "longitudinal", 10),
|
||||
("副驾往后移动到20%", "longitudinal", 20),
|
||||
("副驾座椅靠背往前移动到10%", "backrest_recline", 10),
|
||||
("副驾座椅靠背往前移动到30%", "backrest_recline", 30),
|
||||
("副驾靠背往后移动到10%", "backrest_recline", 10),
|
||||
("副驾靠背往后移动到30%", "backrest_recline", 30),
|
||||
("副驾推托往上移动到20%", "legrest_height", 20),
|
||||
("副驾推托往上移动到40%", "legrest_height", 40),
|
||||
("副驾推托往下移动到20%", "legrest_height", 20),
|
||||
("副驾推托往下移动到40%", "legrest_height", 40),
|
||||
)
|
||||
|
||||
for utterance, axis, position in cases:
|
||||
with self.subTest(utterance=utterance):
|
||||
result = parse_local_intent(utterance, self.motion_context())
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(len(result["actions"]), 1)
|
||||
action = result["actions"][0]
|
||||
self.assertEqual(action["axis"], axis)
|
||||
self.assertEqual(action["position_pct"], position)
|
||||
self.assertNotIn("motion_intent", action)
|
||||
|
||||
def test_motion_verbs_with_position_markers_are_absolute(self) -> None:
|
||||
cases = (
|
||||
("副驾滑轨移动至60%", "longitudinal", 60),
|
||||
("副驾靠背转动到30%", "backrest_recline", 30),
|
||||
("副驾腿托调节至40%", "legrest_height", 40),
|
||||
("副驾座椅高度设置为20%", "cushion_tilt", 20),
|
||||
("副驾靠背往后转动到35%", "backrest_recline", 35),
|
||||
("副驾腿托往上设置为45%", "legrest_height", 45),
|
||||
("副驾靠背旋转到130度", "backrest_recline", 51),
|
||||
)
|
||||
|
||||
for utterance, axis, position in cases:
|
||||
with self.subTest(utterance=utterance):
|
||||
result = parse_local_intent(utterance, self.motion_context())
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(len(result["actions"]), 1)
|
||||
action = result["actions"][0]
|
||||
self.assertEqual(action["axis"], axis)
|
||||
self.assertEqual(action["position_pct"], position)
|
||||
self.assertNotIn("motion_intent", action)
|
||||
|
||||
def test_rejects_relative_motion_without_state_and_clamps_endpoint_overflow(self) -> None:
|
||||
missing = parse_local_intent("靠背向前移动10度")
|
||||
context = self.motion_context()
|
||||
|
||||
@@ -930,6 +930,52 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
|
||||
def test_passive_snapshot_reads_all_passenger_axes_and_thermal_state(self) -> None:
|
||||
bus = ReactiveFakeBus(self.matrix)
|
||||
bus.received.extend(
|
||||
[
|
||||
bus._frame(
|
||||
"T2G_Seat_Move_System_Status_301",
|
||||
{
|
||||
"Ack": 1,
|
||||
"System_State": 2,
|
||||
"Last_Sequence": 9,
|
||||
"Last_Opcode": 1,
|
||||
"Position_Valid_Mask": 0x0F,
|
||||
"Firmware_Revision": 1,
|
||||
},
|
||||
),
|
||||
bus._frame(
|
||||
"T2G_Seat_Move_Diagnostic_303",
|
||||
{
|
||||
"Motor_Sub_ID": 0,
|
||||
"Hall_Count": 120,
|
||||
"Max_Hall_Count": 480,
|
||||
"Hall_Speed_Half_Pps": 4,
|
||||
},
|
||||
),
|
||||
bus._frame(
|
||||
"T2G_Seat_Move_Current_Diagnostic_304",
|
||||
{
|
||||
"Motor_Sub_ID": 0,
|
||||
"Current_Sample_Valid": 1,
|
||||
"Current_Baseline_Valid": 1,
|
||||
"Current_Raw": 240,
|
||||
"Current_Baseline_Raw": 100,
|
||||
"Stall_Current_Delta_Threshold_Raw": 500,
|
||||
},
|
||||
),
|
||||
bus._frame(
|
||||
"T2G_Seat_Thermal_Current_Diagnostic_305",
|
||||
{
|
||||
"Fan_Current_Sample_Valid": 1,
|
||||
"Heater_Current_Sample_Valid": 1,
|
||||
"Fan_Running": 1,
|
||||
"Heater_Level": 2,
|
||||
"Fan_Current_Raw": 300,
|
||||
"Heater_Current_Raw": 450,
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
for motor, value_x100 in enumerate((1200, 3400, 5600, 7800)):
|
||||
bus.received.append(
|
||||
bus._frame(
|
||||
@@ -982,6 +1028,26 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
self.assertEqual(physical["ventilation"]["state"], "off")
|
||||
self.assertEqual(physical["heating"]["state"], "on")
|
||||
self.assertEqual(physical["heating"]["level"], 2)
|
||||
self.assertEqual(physical["channel"], 0)
|
||||
self.assertEqual(
|
||||
physical["protocol_profile"], "passenger-seat-native-can0-v1"
|
||||
)
|
||||
self.assertEqual(physical["system"]["position_valid_mask"], 0x0F)
|
||||
self.assertEqual(
|
||||
physical["diagnostics"]["motors"]["longitudinal"]["hall_count"],
|
||||
120,
|
||||
)
|
||||
self.assertEqual(
|
||||
physical["diagnostics"]["motors"]["longitudinal"]["current"]["raw"],
|
||||
240,
|
||||
)
|
||||
self.assertEqual(
|
||||
physical["diagnostics"]["thermal"]["heater_current_raw"], 450
|
||||
)
|
||||
self.assertEqual(
|
||||
physical["observed_frame_ids"],
|
||||
["0x301", "0x302", "0x303", "0x304", "0x305", "0x4FA"],
|
||||
)
|
||||
self.assertTrue(bus.closed)
|
||||
|
||||
def test_executor_sends_firmware_valid_stop_all_before_four_axis_zero(self) -> None:
|
||||
@@ -1516,11 +1582,18 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
self.matrix, lambda: bus, ack_timeout_ms=20
|
||||
).execute(plan, config)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
self.assertEqual([item.data[0] for item in bus.sent], [9, 1, 9])
|
||||
self.assertTrue(
|
||||
report["spec"]["results"][-1]["telemetry"]["stop_all_attempted"]
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
report["spec"]["verification_status"],
|
||||
"submitted_unverified",
|
||||
)
|
||||
self.assertEqual([item.data[0] for item in bus.sent], [9, 1, 9])
|
||||
telemetry = report["spec"]["results"][-1]["telemetry"]
|
||||
self.assertTrue(telemetry["stop_all_attempted"])
|
||||
self.assertEqual(telemetry["delivery_status"], "submitted")
|
||||
self.assertEqual(telemetry["command_frame"]["frame_id"], "0x300")
|
||||
self.assertEqual(telemetry["verification_status"], "submitted_unverified")
|
||||
self.assertFalse(telemetry["physical_effect_confirmed"])
|
||||
|
||||
def test_executor_waits_for_real_thermal_status(self) -> None:
|
||||
config = function_config(
|
||||
@@ -1536,6 +1609,7 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(telemetry["feedback_frame"]["frame_id"], "0x4FA")
|
||||
self.assertEqual(telemetry["feedback_value"], 3)
|
||||
self.assertEqual(telemetry["verification_status"], "verified_success")
|
||||
|
||||
def test_executor_preserves_can_adapter_open_failure(self) -> None:
|
||||
config = function_config(
|
||||
@@ -1553,6 +1627,9 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
failure = report["spec"]["results"][-1]
|
||||
self.assertEqual(failure["telemetry"]["error_type"], "CanGatewayError")
|
||||
self.assertEqual(
|
||||
failure["telemetry"]["verification_status"], "failed_or_blocked"
|
||||
)
|
||||
self.assertIn("ZCAN_OpenDevice failed", failure["message"])
|
||||
self.assertFalse(failure["telemetry"]["stop_all_attempted"])
|
||||
|
||||
|
||||
@@ -239,6 +239,9 @@ class ConversationStateStore:
|
||||
with self._lock:
|
||||
state = self._state(conversation_id)
|
||||
passenger = state["devices"]["passenger"]
|
||||
feedback_source = str(physical_state.get("source", "physical_can"))
|
||||
if feedback_source not in {"physical_can", "wireless_mqtt_ch0"}:
|
||||
feedback_source = "physical_can"
|
||||
for axis, feedback in physical_state.get("position", {}).items():
|
||||
if axis not in MOTION_INITIAL_POSITIONS or not isinstance(
|
||||
feedback, dict
|
||||
@@ -260,7 +263,7 @@ class ConversationStateStore:
|
||||
position_state["position_valid"] = bool(
|
||||
feedback["position_valid"]
|
||||
)
|
||||
position_state["feedback_source"] = "physical_can"
|
||||
position_state["feedback_source"] = feedback_source
|
||||
passenger["position"][str(axis)] = position_state
|
||||
|
||||
for feature in THERMAL_FUNCTIONS:
|
||||
@@ -277,7 +280,20 @@ class ConversationStateStore:
|
||||
passenger[feature] = {
|
||||
"state": state_value,
|
||||
"level": level,
|
||||
"feedback_source": "physical_can",
|
||||
"feedback_source": feedback_source,
|
||||
}
|
||||
|
||||
dynamic_mode = physical_state.get("dynamic_mode")
|
||||
if isinstance(dynamic_mode, dict):
|
||||
dynamic_state = str(dynamic_mode.get("state", "idle"))
|
||||
passenger["dynamic_mode"] = {
|
||||
**deepcopy(dynamic_mode),
|
||||
"state": (
|
||||
"running"
|
||||
if dynamic_state in {"entry", "running"}
|
||||
else dynamic_state
|
||||
),
|
||||
"feedback_source": feedback_source,
|
||||
}
|
||||
|
||||
state["physical_state_updated_at"] = str(
|
||||
@@ -567,14 +583,19 @@ class ConversationStateStore:
|
||||
device = state["domain_devices"][domain]
|
||||
|
||||
if domain == "climate":
|
||||
# The trusted CAN command vocabulary uses the concise token
|
||||
# ``rear`` while the conversation state stores that same HVAC
|
||||
# zone as ``rear_row``. Keep the public command stable and
|
||||
# normalize only at the state boundary.
|
||||
zone_target = "rear_row" if target == "rear" else target
|
||||
if capability == "climate.set_power":
|
||||
device["zones"][target]["power"] = parameters["state"]
|
||||
device["zones"][zone_target]["power"] = parameters["state"]
|
||||
if target == "vehicle":
|
||||
device["power"] = parameters["state"]
|
||||
for zone in device["zones"].values():
|
||||
zone["power"] = parameters["state"]
|
||||
elif capability == "climate.set_temperature":
|
||||
device["zones"][target]["temperature_c"] = parameters[
|
||||
device["zones"][zone_target]["temperature_c"] = parameters[
|
||||
"temperature_c"
|
||||
]
|
||||
if target == "vehicle":
|
||||
@@ -589,7 +610,7 @@ class ConversationStateStore:
|
||||
elif capability == "climate.set_front_defrost":
|
||||
device["front_defrost"] = parameters["state"]
|
||||
elif capability == "climate.set_outlet_mode":
|
||||
device["zones"][target]["outlet_mode"] = parameters["mode"]
|
||||
device["zones"][zone_target]["outlet_mode"] = parameters["mode"]
|
||||
elif capability == "climate.set_control_mode":
|
||||
device["control_mode"] = parameters["mode"]
|
||||
device["feedback_source"] = feedback_source
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Deterministic batches submitted by the vehicle-device control panel.
|
||||
|
||||
The browser sends only canonical commands from the checked CAN vocabulary.
|
||||
This module validates the complete batch and builds the same intermediate
|
||||
actions used by the normal compiler, without natural-language or model parsing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from cognitive_agent.application.native_can_intent import (
|
||||
parse_exact_native_can_command,
|
||||
)
|
||||
|
||||
|
||||
MAX_DIRECT_CAN_COMMANDS = 32
|
||||
MAX_DIRECT_CAN_COMMAND_LENGTH = 160
|
||||
|
||||
_PASSENGER_POSITION_COMMAND = re.compile(
|
||||
r"seat passenger "
|
||||
r"(horizontal|basin|backrest|legrest|longitudinal|height) "
|
||||
r"position (\d+)"
|
||||
)
|
||||
_PASSENGER_RELATIVE_COMMAND = re.compile(
|
||||
r"seat passenger "
|
||||
r"(horizontal|basin|backrest|legrest|longitudinal|height) "
|
||||
r"relative (forward|backward|up|down) (\d+)"
|
||||
)
|
||||
_PASSENGER_THERMAL_COMMAND = re.compile(
|
||||
r"seat passenger (vent|heat) (off|low|medium|high)"
|
||||
)
|
||||
_PASSENGER_DYNAMIC_COMMAND = re.compile(
|
||||
r"seat passenger dynamic (?:(?:start )?(rocking-horse|cradle)|(stop))"
|
||||
)
|
||||
|
||||
_PASSENGER_AXES = {
|
||||
"horizontal": "longitudinal",
|
||||
"basin": "cushion_tilt",
|
||||
"longitudinal": "longitudinal",
|
||||
"height": "cushion_tilt",
|
||||
"backrest": "backrest_recline",
|
||||
"legrest": "legrest_height",
|
||||
}
|
||||
|
||||
_PASSENGER_RELATIVE_DIRECTIONS = {
|
||||
"horizontal": {"forward": "decrease", "backward": "increase"},
|
||||
"longitudinal": {"forward": "decrease", "backward": "increase"},
|
||||
"basin": {"up": "decrease", "down": "increase"},
|
||||
"height": {"up": "decrease", "down": "increase"},
|
||||
"backrest": {"forward": "decrease", "backward": "increase"},
|
||||
"legrest": {"up": "increase", "down": "decrease"},
|
||||
}
|
||||
|
||||
_PASSENGER_THERMAL_LEVELS = {"low": 1, "medium": 2, "high": 3}
|
||||
|
||||
|
||||
def _empty_direct_intent() -> dict[str, Any]:
|
||||
return {
|
||||
"actions": [],
|
||||
"domain_actions": [],
|
||||
"native_can_actions": [],
|
||||
"preset_actions": [],
|
||||
"preset_events": [],
|
||||
"deferred_requests": [],
|
||||
"model_requests": [],
|
||||
"ambiguities": [],
|
||||
"decision_summary": [],
|
||||
"semantic_constraints": [],
|
||||
"unresolved_spans": [],
|
||||
"detected_domains": [],
|
||||
"confidence": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def build_direct_can_intent(
|
||||
commands: object,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
"""Validate a canonical command batch and return compiler-ready actions."""
|
||||
|
||||
if not isinstance(commands, list) or not commands:
|
||||
raise ValueError("can_commands must be a non-empty array")
|
||||
if len(commands) > MAX_DIRECT_CAN_COMMANDS:
|
||||
raise ValueError(
|
||||
f"can_commands must contain at most {MAX_DIRECT_CAN_COMMANDS} commands"
|
||||
)
|
||||
|
||||
normalized_commands: list[str] = []
|
||||
parsed = _empty_direct_intent()
|
||||
seen_commands: set[str] = set()
|
||||
|
||||
for raw_command in commands:
|
||||
if not isinstance(raw_command, str):
|
||||
raise ValueError("every can command must be a string")
|
||||
command = " ".join(raw_command.strip().lower().split())
|
||||
if not command or len(command) > MAX_DIRECT_CAN_COMMAND_LENGTH:
|
||||
raise ValueError(
|
||||
"every can command must contain 1 to "
|
||||
f"{MAX_DIRECT_CAN_COMMAND_LENGTH} characters"
|
||||
)
|
||||
if command in seen_commands:
|
||||
continue
|
||||
|
||||
native_actions = parse_exact_native_can_command(command)
|
||||
if native_actions:
|
||||
for action in native_actions:
|
||||
direct_action = dict(action)
|
||||
direct_action["parse_source"] = "direct_control"
|
||||
direct_action["source_span"] = command
|
||||
parsed["native_can_actions"].append(direct_action)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交白名单命令:{command}"
|
||||
)
|
||||
domain = str(direct_action.get("domain", "vehicle"))
|
||||
if domain not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append(domain)
|
||||
else:
|
||||
passenger_position = _PASSENGER_POSITION_COMMAND.fullmatch(command)
|
||||
if passenger_position:
|
||||
position = int(passenger_position[2])
|
||||
if not 0 <= position <= 100:
|
||||
raise ValueError(
|
||||
f"CAN0 passenger-seat position is out of range: {command}"
|
||||
)
|
||||
parsed["actions"].append(
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": _PASSENGER_AXES[passenger_position[1]],
|
||||
"position_pct": position,
|
||||
"parse_source": "direct_control",
|
||||
"source_span": command,
|
||||
}
|
||||
)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交 CAN0 白名单命令:{command}"
|
||||
)
|
||||
if "seat" not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append("seat")
|
||||
else:
|
||||
relative = _PASSENGER_RELATIVE_COMMAND.fullmatch(command)
|
||||
if relative:
|
||||
semantic_axis = relative[1]
|
||||
axis = _PASSENGER_AXES[semantic_axis]
|
||||
direction = relative[2]
|
||||
operation = _PASSENGER_RELATIVE_DIRECTIONS.get(
|
||||
semantic_axis, {}
|
||||
).get(direction)
|
||||
delta = int(relative[3])
|
||||
if operation is None:
|
||||
raise ValueError(
|
||||
f"CAN0 passenger-seat direction is invalid: {command}"
|
||||
)
|
||||
if not 1 <= delta <= 100:
|
||||
raise ValueError(
|
||||
f"CAN0 passenger-seat relative amount is out of range: {command}"
|
||||
)
|
||||
position_state = (
|
||||
(context or {})
|
||||
.get("devices", {})
|
||||
.get("passenger", {})
|
||||
.get("position", {})
|
||||
.get(axis, {})
|
||||
)
|
||||
current = position_state.get("position_pct")
|
||||
if (
|
||||
isinstance(current, bool)
|
||||
or not isinstance(current, (int, float))
|
||||
or not 0 <= float(current) <= 100
|
||||
):
|
||||
raise ValueError(
|
||||
"CAN0 passenger-seat relative control requires a valid "
|
||||
f"current {axis} position"
|
||||
)
|
||||
signed_delta = delta if operation == "increase" else -delta
|
||||
raw_target = round(float(current)) + signed_delta
|
||||
target = max(0, min(100, raw_target))
|
||||
applied_delta = abs(target - round(float(current)))
|
||||
parsed["actions"].append(
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": axis,
|
||||
"position_pct": target,
|
||||
"motion_intent": {
|
||||
"mode": "relative",
|
||||
"direction": operation,
|
||||
"start_position_pct": round(float(current)),
|
||||
"delta_position_pct": applied_delta,
|
||||
**(
|
||||
{
|
||||
"requested_delta_position_pct": delta,
|
||||
"range_clamped": True,
|
||||
}
|
||||
if applied_delta != delta
|
||||
else {}
|
||||
),
|
||||
},
|
||||
"parse_source": "direct_control",
|
||||
"source_span": command,
|
||||
}
|
||||
)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交 CAN0 相对移动白名单命令:{command}"
|
||||
)
|
||||
if "seat" not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append("seat")
|
||||
else:
|
||||
thermal = _PASSENGER_THERMAL_COMMAND.fullmatch(command)
|
||||
if thermal:
|
||||
setting = thermal[2]
|
||||
action = {
|
||||
"target": "passenger",
|
||||
"function": (
|
||||
"ventilation" if thermal[1] == "vent" else "heating"
|
||||
),
|
||||
"state": "off" if setting == "off" else "on",
|
||||
"parse_source": "direct_control",
|
||||
"source_span": command,
|
||||
}
|
||||
if setting != "off":
|
||||
action["requested_level"] = _PASSENGER_THERMAL_LEVELS[
|
||||
setting
|
||||
]
|
||||
parsed["actions"].append(action)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交 CAN0 热舒适白名单命令:{command}"
|
||||
)
|
||||
if "seat" not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append("seat")
|
||||
normalized_commands.append(command)
|
||||
seen_commands.add(command)
|
||||
continue
|
||||
|
||||
dynamic = _PASSENGER_DYNAMIC_COMMAND.fullmatch(command)
|
||||
if dynamic and dynamic[1]:
|
||||
mode = dynamic[1]
|
||||
parsed["actions"].append(
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "dynamic_mode",
|
||||
"mode_id": 1 if mode == "rocking-horse" else 2,
|
||||
"mode_name": (
|
||||
"木马模式"
|
||||
if mode == "rocking-horse"
|
||||
else "动态摇篮模式"
|
||||
),
|
||||
"repeat": "until_stopped_or_timeout",
|
||||
"max_runtime_ms": 600_000,
|
||||
"parse_source": "direct_control",
|
||||
"source_span": command,
|
||||
}
|
||||
)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交 CAN0 白名单命令:{command}"
|
||||
)
|
||||
if "seat" not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append("seat")
|
||||
elif dynamic and dynamic[2] == "stop":
|
||||
parsed["actions"].append(
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "dynamic_mode_stop",
|
||||
"parse_source": "direct_control",
|
||||
"source_span": command,
|
||||
}
|
||||
)
|
||||
parsed["decision_summary"].append(
|
||||
f"设备控件直接提交 CAN0 白名单命令:{command}"
|
||||
)
|
||||
if "seat" not in parsed["detected_domains"]:
|
||||
parsed["detected_domains"].append("seat")
|
||||
else:
|
||||
raise ValueError(
|
||||
f"CAN control command is not in the exact whitelist: {command}"
|
||||
)
|
||||
|
||||
normalized_commands.append(command)
|
||||
seen_commands.add(command)
|
||||
|
||||
if not normalized_commands:
|
||||
raise ValueError("can_commands must contain at least one unique command")
|
||||
return normalized_commands, parsed
|
||||
@@ -78,6 +78,9 @@ class ConfiguredDomainRuntime:
|
||||
"zone": action["target"],
|
||||
},
|
||||
"parameters": dict(action["parameters"]),
|
||||
"parse_source": str(
|
||||
action.get("parse_source", "local_rule")
|
||||
),
|
||||
"timeout_ms": 8000 if self.domain == "roof" else 4000,
|
||||
"failure_policy": "abort",
|
||||
}
|
||||
|
||||
@@ -1,369 +1,52 @@
|
||||
"""Wireless passenger-seat executor for the seat-ota runtime protocol."""
|
||||
"""Wireless passenger-seat executor for the firmware-native CAN0 protocol.
|
||||
|
||||
The module name is retained for import compatibility. Control is deliberately
|
||||
not translated to the older seat-ota ``0x6A0`` runtime: the head unit must
|
||||
relay the same classic-CAN ``0x300/0x29E/0x3CE`` frames used by the trusted
|
||||
passenger-seat firmware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from copy import deepcopy
|
||||
from typing import Any, Callable
|
||||
|
||||
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
|
||||
from seat_domain import HardwareExecutionError, HardwareSeatExecutor
|
||||
from cognitive_agent.infrastructure.can.gateway import CanBus
|
||||
from seat_domain import HardwareSeatExecutor
|
||||
|
||||
|
||||
OTA_CONTROL_ID = 0x6A0
|
||||
OTA_RESPONSE_ID = 0x6A2
|
||||
OTA_CONTROL_MAGIC = 0xC7
|
||||
OTA_RESPONSE_MAGIC = 0x7C
|
||||
OTA_OPCODE_SEAT_STOP = 0x63
|
||||
OTA_OPCODE_SEAT_MOVE = 0x64
|
||||
OTA_OPCODE_SEAT_HEARTBEAT = 0x65
|
||||
OTA_OPCODE_SEAT_DYNAMIC_START = 0x66
|
||||
OTA_SEAT_AXIS_ALL = 0xFF
|
||||
OTA_RESPONSE_TIMEOUT_MS = 1_500
|
||||
OTA_HEARTBEAT_INTERVAL_MS = 500
|
||||
OTA_DYNAMIC_DURATION_SECONDS = 300
|
||||
# The seat domain's semantic mode IDs predate the seat-ota firmware enum.
|
||||
# Keep the public meaning stable while sending the firmware selector exactly.
|
||||
OTA_DYNAMIC_SELECTOR_BY_SEMANTIC_MODE = {1: 2, 2: 1}
|
||||
OTA_STATUS_NAMES = {
|
||||
0x00: "OK",
|
||||
0x01: "BAD_MAGIC",
|
||||
0x02: "BAD_OPCODE",
|
||||
0x03: "BAD_STATE",
|
||||
0x04: "BAD_ARGUMENT",
|
||||
0x0C: "RX_OVERFLOW",
|
||||
0x0D: "BUS_OFF",
|
||||
}
|
||||
class MqttNativeSeatExecutor:
|
||||
"""Run the native passenger-seat executor over the MQTT CAN0 bus.
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class MqttOtaSeatExecutor:
|
||||
"""Map verified seat plans to the board's bounded OTA runtime opcodes.
|
||||
|
||||
Flash, bootloader-entry, calibration, homing and test opcodes are not
|
||||
represented here. The MQTT bus performs a second payload-level check
|
||||
before it will publish any 0x6A0 frame.
|
||||
``HardwareSeatExecutor`` remains the single implementation of frame encoding
|
||||
and sequence handling. This adapter selects its publish-only evidence mode
|
||||
and changes report provenance from a local bus to the wireless MQTT relay;
|
||||
physical completion is synchronized later from the state topic.
|
||||
"""
|
||||
|
||||
_sequence_lock = threading.Lock()
|
||||
_sequence = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_executor: HardwareSeatExecutor,
|
||||
native_executor: HardwareSeatExecutor,
|
||||
bus_factory: Callable[[], CanBus],
|
||||
) -> None:
|
||||
self.status_executor = status_executor
|
||||
self.native_executor = native_executor
|
||||
self.bus_factory = bus_factory
|
||||
|
||||
@classmethod
|
||||
def _next_sequence(cls) -> int:
|
||||
with cls._sequence_lock:
|
||||
cls._sequence = (cls._sequence + 1) & 0xFF
|
||||
if cls._sequence == 0:
|
||||
cls._sequence = 1
|
||||
return cls._sequence
|
||||
|
||||
def read_physical_state(self) -> dict[str, Any]:
|
||||
return self.status_executor.read_physical_state()
|
||||
# Keep the executor on the explicitly supplied remote CAN0 factory.
|
||||
# In particular, never fall back to a local ZLG adapter or firmware
|
||||
# programmer when wireless control is selected.
|
||||
self.native_executor.bus_factory = bus_factory
|
||||
self.native_executor.feedback_required = False
|
||||
|
||||
@staticmethod
|
||||
def _operation_debug_command(operation: dict[str, Any]) -> str:
|
||||
capability = str(operation["capability"])
|
||||
parameters = operation.get("parameters", {})
|
||||
axes = {
|
||||
"seat.set_longitudinal_position": "longitudinal",
|
||||
"seat.set_cushion_tilt": "height",
|
||||
"seat.set_backrest_recline": "backrest",
|
||||
"seat.set_legrest_height": "legrest",
|
||||
}
|
||||
if capability in axes:
|
||||
return (
|
||||
f"seat passenger {axes[capability]} position "
|
||||
f"{parameters['position_pct']}"
|
||||
)
|
||||
if capability == "seat.start_dynamic_mode":
|
||||
mode = "rocking-horse" if int(parameters["mode_id"]) == 1 else "cradle"
|
||||
return f"seat passenger dynamic start {mode}"
|
||||
if capability == "seat.stop_dynamic_mode":
|
||||
return "seat passenger dynamic stop"
|
||||
return capability
|
||||
"""Expose the shared readable command formatter to existing callers."""
|
||||
|
||||
@staticmethod
|
||||
def _axis_state(frame: CanFrame) -> dict[str, Any] | None:
|
||||
if frame.arbitration_id != 0x302 or len(frame.data) != 8:
|
||||
return None
|
||||
flags = frame.data[2]
|
||||
return {
|
||||
"axis": frame.data[0],
|
||||
"moving": bool(flags & (1 << 2)),
|
||||
"position_valid": bool(flags & (1 << 3)),
|
||||
"fault": bool(flags & (1 << 7)) or (frame.data[1] >> 4) == 7,
|
||||
"value_x100": int.from_bytes(frame.data[5:7], "little"),
|
||||
"frame": frame,
|
||||
}
|
||||
return HardwareSeatExecutor._operation_debug_command(operation) # noqa: SLF001
|
||||
|
||||
def _receive_axis(
|
||||
self,
|
||||
bus: CanBus,
|
||||
axis: int,
|
||||
timeout_ms: int,
|
||||
) -> dict[str, Any]:
|
||||
deadline = time.monotonic() + max(1, timeout_ms) / 1_000
|
||||
while True:
|
||||
remaining_ms = round((deadline - time.monotonic()) * 1_000)
|
||||
if remaining_ms <= 0:
|
||||
raise HardwareExecutionError(
|
||||
f"timed out after {timeout_ms} ms waiting for wireless seat axis {axis}"
|
||||
)
|
||||
frame = bus.receive(remaining_ms)
|
||||
if frame is None:
|
||||
raise HardwareExecutionError(
|
||||
f"timed out after {timeout_ms} ms waiting for wireless seat axis {axis}"
|
||||
)
|
||||
state = self._axis_state(frame)
|
||||
if state is not None and state["axis"] == axis:
|
||||
return state
|
||||
|
||||
def _wait_response(
|
||||
self,
|
||||
bus: CanBus,
|
||||
sequence: int,
|
||||
opcode: int,
|
||||
timeout_ms: int = OTA_RESPONSE_TIMEOUT_MS,
|
||||
) -> tuple[CanFrame, int]:
|
||||
deadline = time.monotonic() + max(1, timeout_ms) / 1_000
|
||||
while True:
|
||||
remaining_ms = round((deadline - time.monotonic()) * 1_000)
|
||||
if remaining_ms <= 0:
|
||||
raise HardwareExecutionError(
|
||||
f"timed out after {timeout_ms} ms waiting for CAN 0x6A2 "
|
||||
f"sequence={sequence} opcode=0x{opcode:02X}"
|
||||
)
|
||||
frame = bus.receive(remaining_ms)
|
||||
if frame is None:
|
||||
raise HardwareExecutionError(
|
||||
f"timed out after {timeout_ms} ms waiting for CAN 0x6A2 "
|
||||
f"sequence={sequence} opcode=0x{opcode:02X}"
|
||||
)
|
||||
if (
|
||||
frame.arbitration_id != OTA_RESPONSE_ID
|
||||
or len(frame.data) != 8
|
||||
or frame.data[0] != OTA_RESPONSE_MAGIC
|
||||
or frame.data[1] != sequence
|
||||
or frame.data[2] != opcode
|
||||
):
|
||||
continue
|
||||
status = frame.data[3]
|
||||
value = int.from_bytes(frame.data[4:8], "little")
|
||||
if status != 0:
|
||||
raise HardwareExecutionError(
|
||||
"seat-ota runtime rejected CAN0 command: "
|
||||
f"status={OTA_STATUS_NAMES.get(status, f'0x{status:02X}')}, "
|
||||
f"opcode=0x{opcode:02X}, value=0x{value:08X}",
|
||||
telemetry={
|
||||
"response_frame": frame.public(),
|
||||
"ota_status": status,
|
||||
"ota_value": value,
|
||||
},
|
||||
)
|
||||
return frame, value
|
||||
|
||||
def _send_control(
|
||||
self,
|
||||
bus: CanBus,
|
||||
opcode: int,
|
||||
selector: int,
|
||||
argument: int,
|
||||
) -> tuple[CanFrame, CanFrame, int]:
|
||||
sequence = self._next_sequence()
|
||||
payload = bytes(
|
||||
(OTA_CONTROL_MAGIC, sequence, opcode, selector)
|
||||
) + int(argument).to_bytes(4, "little")
|
||||
command_frame = bus.send(OTA_CONTROL_ID, payload)
|
||||
response_frame, value = self._wait_response(bus, sequence, opcode)
|
||||
return command_frame, response_frame, value
|
||||
|
||||
def _best_effort_stop(self, bus: CanBus) -> None:
|
||||
try:
|
||||
self._send_control(
|
||||
bus,
|
||||
OTA_OPCODE_SEAT_STOP,
|
||||
OTA_SEAT_AXIS_ALL,
|
||||
0,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - preserve the primary failure
|
||||
pass
|
||||
|
||||
def _execute_move(
|
||||
self,
|
||||
bus: CanBus,
|
||||
command: dict[str, Any],
|
||||
operations: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
signals = command["signals"]
|
||||
legacy_opcode = int(signals["Opcode"])
|
||||
if legacy_opcode not in {0x01, 0x0B}:
|
||||
raise HardwareExecutionError(
|
||||
"wireless seat-ota supports absolute four-axis targets only; "
|
||||
f"legacy opcode 0x{legacy_opcode:02X} was not sent"
|
||||
)
|
||||
axis = int(signals["Motor_Selector"])
|
||||
if axis not in range(4):
|
||||
raise HardwareExecutionError("wireless seat axis must be between 0 and 3")
|
||||
target_x100 = int(command["completion"]["value"])
|
||||
tolerance_x100 = int(command["completion"].get("tolerance", 100))
|
||||
target_permille = max(0, min(1_000, round(target_x100 / 10)))
|
||||
|
||||
preflight = self._receive_axis(bus, axis, OTA_RESPONSE_TIMEOUT_MS)
|
||||
if preflight["fault"] or not preflight["position_valid"]:
|
||||
raise HardwareExecutionError(
|
||||
f"wireless seat axis {axis} is faulted or position-invalid before send",
|
||||
telemetry={"preflight_frame": preflight["frame"].public()},
|
||||
)
|
||||
|
||||
command_frame, response_frame, job_state = self._send_control(
|
||||
bus,
|
||||
OTA_OPCODE_SEAT_MOVE,
|
||||
axis,
|
||||
target_permille,
|
||||
)
|
||||
deadline = time.monotonic() + int(command["completion"]["timeout_ms"]) / 1_000
|
||||
next_heartbeat = time.monotonic() + OTA_HEARTBEAT_INTERVAL_MS / 1_000
|
||||
feedback = preflight
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
if now >= deadline:
|
||||
raise HardwareExecutionError(
|
||||
f"wireless seat axis {axis} did not reach target "
|
||||
f"{target_x100}±{tolerance_x100}",
|
||||
telemetry={"last_feedback_frame": feedback["frame"].public()},
|
||||
)
|
||||
wait_until = min(deadline, next_heartbeat)
|
||||
frame = bus.receive(max(1, round((wait_until - now) * 1_000)))
|
||||
state = self._axis_state(frame) if frame is not None else None
|
||||
if state is not None and state["axis"] == axis:
|
||||
feedback = state
|
||||
if state["fault"]:
|
||||
raise HardwareExecutionError(
|
||||
f"wireless seat axis {axis} reported a driver fault",
|
||||
telemetry={"feedback_frame": state["frame"].public()},
|
||||
)
|
||||
if (
|
||||
state["position_valid"]
|
||||
and not state["moving"]
|
||||
and abs(state["value_x100"] - target_x100) <= tolerance_x100
|
||||
):
|
||||
break
|
||||
if time.monotonic() >= next_heartbeat:
|
||||
self._send_control(
|
||||
bus,
|
||||
OTA_OPCODE_SEAT_HEARTBEAT,
|
||||
axis,
|
||||
0,
|
||||
)
|
||||
next_heartbeat = (
|
||||
time.monotonic() + OTA_HEARTBEAT_INTERVAL_MS / 1_000
|
||||
)
|
||||
|
||||
output = []
|
||||
for operation in operations:
|
||||
output.append(
|
||||
{
|
||||
"ref": operation["id"],
|
||||
"domain": "seat",
|
||||
"status": "succeeded",
|
||||
"message": (
|
||||
"seat-ota 0x6A2 accepted the MOVE lease and fresh CAN0 "
|
||||
"position telemetry confirmed the target"
|
||||
),
|
||||
"telemetry": {
|
||||
"motion_axis": operation["capability"].removeprefix(
|
||||
"seat.set_"
|
||||
),
|
||||
"effective_position_pct": round(
|
||||
feedback["value_x100"] / 100
|
||||
),
|
||||
"position_valid": True,
|
||||
"ota_target_permille": target_permille,
|
||||
"ota_job_state": job_state,
|
||||
"command_frame": command_frame.public(),
|
||||
"response_frame": response_frame.public(),
|
||||
"feedback_frame": feedback["frame"].public(),
|
||||
"hardware_feedback": True,
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
def _execute_dynamic(
|
||||
self,
|
||||
bus: CanBus,
|
||||
command: dict[str, Any],
|
||||
operations: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
if len(operations) != 1:
|
||||
raise HardwareExecutionError(
|
||||
"one wireless seat dynamic command must reference one operation"
|
||||
)
|
||||
operation = operations[0]
|
||||
stopping = operation["capability"] == "seat.stop_dynamic_mode"
|
||||
mode_id = 0 if stopping else int(operation["parameters"]["mode_id"])
|
||||
opcode = OTA_OPCODE_SEAT_STOP if stopping else OTA_OPCODE_SEAT_DYNAMIC_START
|
||||
selector = (
|
||||
OTA_SEAT_AXIS_ALL
|
||||
if stopping
|
||||
else OTA_DYNAMIC_SELECTOR_BY_SEMANTIC_MODE[mode_id]
|
||||
)
|
||||
duration = 0 if stopping else OTA_DYNAMIC_DURATION_SECONDS
|
||||
command_frame, response_frame, job_state = self._send_control(
|
||||
bus,
|
||||
opcode,
|
||||
selector,
|
||||
duration,
|
||||
)
|
||||
active = bool(job_state & 1)
|
||||
job_type = (job_state >> 4) & 0x0F
|
||||
if (stopping and active) or (not stopping and (not active or job_type != 4)):
|
||||
raise HardwareExecutionError(
|
||||
"seat-ota response did not confirm the requested dynamic state",
|
||||
telemetry={
|
||||
"response_frame": response_frame.public(),
|
||||
"ota_job_state": job_state,
|
||||
},
|
||||
)
|
||||
return [
|
||||
{
|
||||
"ref": operation["id"],
|
||||
"domain": "seat",
|
||||
"status": "succeeded",
|
||||
"message": (
|
||||
"seat-ota 0x6A2 confirmed dynamic mode stopped"
|
||||
if stopping
|
||||
else "seat-ota 0x6A2 confirmed the bounded dynamic mode is active"
|
||||
),
|
||||
"telemetry": {
|
||||
"dynamic_mode_id": mode_id,
|
||||
"ota_dynamic_selector": None if stopping else selector,
|
||||
"dynamic_mode_name": (
|
||||
"" if stopping else "木马模式" if mode_id == 1 else "动态摇篮模式"
|
||||
),
|
||||
"dynamic_state": 0 if stopping else 1,
|
||||
"repeat": "none" if stopping else "bounded_runtime",
|
||||
"max_runtime_ms": 0 if stopping else duration * 1_000,
|
||||
"ota_job_state": job_state,
|
||||
"command_frame": command_frame.public(),
|
||||
"response_frame": response_frame.public(),
|
||||
"hardware_feedback": True,
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
def read_physical_state(self) -> dict[str, Any]:
|
||||
state = deepcopy(self.native_executor.read_physical_state())
|
||||
state["source"] = "wireless_mqtt_ch0"
|
||||
return state
|
||||
|
||||
def execute(
|
||||
self,
|
||||
@@ -371,127 +54,30 @@ class MqttOtaSeatExecutor:
|
||||
function_config: dict[str, Any],
|
||||
actuator_state: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del actuator_state
|
||||
started_at = _now()
|
||||
operations = {
|
||||
item["id"]: item for item in function_config["spec"]["operations"]
|
||||
}
|
||||
results: list[dict[str, Any]] = []
|
||||
bus: CanBus | None = None
|
||||
motion_armed = False
|
||||
physical_state: dict[str, Any] | None = None
|
||||
with HardwareSeatExecutor._adapter_lock: # noqa: SLF001 - shared CAN0 owner
|
||||
try:
|
||||
bus = self.bus_factory()
|
||||
previous_offset = 0
|
||||
for command in command_plan["spec"]["commands"]:
|
||||
offset = int(command.get("send_offset_ms", 0))
|
||||
if offset > previous_offset:
|
||||
time.sleep((offset - previous_offset) / 1_000)
|
||||
previous_offset = offset
|
||||
referenced = [
|
||||
operations[item]
|
||||
for item in command["operation_ref"].split(",")
|
||||
]
|
||||
publication_start = len(
|
||||
getattr(bus, "published_messages", ())
|
||||
)
|
||||
set_debug_command = getattr(bus, "set_debug_command", None)
|
||||
if callable(set_debug_command):
|
||||
set_debug_command(
|
||||
"; ".join(
|
||||
self._operation_debug_command(item)
|
||||
for item in referenced
|
||||
)
|
||||
)
|
||||
if command["message"] != "T2G_Seat_Move_Command_300":
|
||||
raise HardwareExecutionError(
|
||||
"seat-ota firmware does not provide wireless thermal control; "
|
||||
f"{command['message']} was not sent"
|
||||
)
|
||||
opcode = int(command["signals"]["Opcode"])
|
||||
if opcode in {0x0E, 0x0F}:
|
||||
motion_armed = opcode == 0x0E
|
||||
command_results = self._execute_dynamic(
|
||||
bus, command, referenced
|
||||
)
|
||||
motion_armed = False
|
||||
else:
|
||||
motion_armed = True
|
||||
command_results = self._execute_move(
|
||||
bus, command, referenced
|
||||
)
|
||||
motion_armed = False
|
||||
mqtt_messages = list(
|
||||
getattr(bus, "published_messages", ())[
|
||||
publication_start:
|
||||
]
|
||||
)
|
||||
if mqtt_messages:
|
||||
for item in command_results:
|
||||
item.setdefault("telemetry", {})[
|
||||
"mqtt_messages"
|
||||
] = mqtt_messages
|
||||
results.extend(command_results)
|
||||
except Exception as exc: # noqa: BLE001 - normalize protocol failures
|
||||
if bus is not None and motion_armed:
|
||||
self._best_effort_stop(bus)
|
||||
reported = {item["ref"] for item in results}
|
||||
operation_ref = next(
|
||||
(item for item in operations if item not in reported),
|
||||
next(iter(operations)),
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"ref": operation_ref,
|
||||
"domain": "seat",
|
||||
"status": "failed",
|
||||
"message": str(exc),
|
||||
"telemetry": {
|
||||
**getattr(exc, "telemetry", {}),
|
||||
"error_type": type(exc).__name__,
|
||||
"stop_all_attempted": bool(bus is not None and motion_armed),
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
if bus is not None:
|
||||
try:
|
||||
physical_state = self.status_executor._capture_physical_state( # noqa: SLF001
|
||||
bus,
|
||||
results,
|
||||
)
|
||||
physical_state["source"] = "wireless_mqtt_ch0"
|
||||
except Exception: # noqa: BLE001 - keep command outcome primary
|
||||
physical_state = None
|
||||
bus.close()
|
||||
|
||||
succeeded = bool(results) and all(
|
||||
item["status"] == "succeeded" for item in results
|
||||
report = self.native_executor.execute(
|
||||
command_plan,
|
||||
function_config,
|
||||
actuator_state,
|
||||
)
|
||||
request_id = function_config["metadata"]["request_id"]
|
||||
spec: dict[str, Any] = {
|
||||
"mode": "hil",
|
||||
"status": "succeeded" if succeeded else "failed",
|
||||
"started_at": started_at,
|
||||
"finished_at": _now(),
|
||||
"results": results,
|
||||
}
|
||||
if physical_state is not None:
|
||||
spec["physical_state"] = physical_state
|
||||
return {
|
||||
"apiVersion": "cognitive-agent/v1alpha1",
|
||||
"kind": "ExecutionReport",
|
||||
"metadata": {
|
||||
"id": f"report-{request_id}",
|
||||
"name": "seat-ota MQTT hardware execution report",
|
||||
"version": "1.0.0",
|
||||
"request_id": request_id,
|
||||
"labels": {
|
||||
"simulation": "false",
|
||||
"feedback": "wireless_mqtt_ch0",
|
||||
},
|
||||
},
|
||||
"spec": spec,
|
||||
}
|
||||
metadata = report.setdefault("metadata", {})
|
||||
metadata["name"] = "native CAN0 MQTT hardware execution report"
|
||||
labels = metadata.setdefault("labels", {})
|
||||
labels["feedback"] = "wireless_mqtt_ch0"
|
||||
labels["seat_feedback"] = "wireless_mqtt_ch0"
|
||||
spec = report.setdefault("spec", {})
|
||||
physical_state = spec.get("physical_state")
|
||||
if isinstance(physical_state, dict):
|
||||
physical_state["source"] = "wireless_mqtt_ch0"
|
||||
for result in spec.get("results", []):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
telemetry = result.setdefault("telemetry", {})
|
||||
if isinstance(telemetry, dict):
|
||||
telemetry["control_transport"] = "wireless_mqtt"
|
||||
telemetry["seat_feedback"] = "wireless_mqtt_ch0"
|
||||
return report
|
||||
|
||||
|
||||
# Compatibility alias for code and saved tests that still import the old
|
||||
# class name. The implementation above never publishes a 0x6A0 control frame.
|
||||
MqttOtaSeatExecutor = MqttNativeSeatExecutor
|
||||
|
||||
@@ -227,6 +227,57 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]:
|
||||
}
|
||||
return decoded
|
||||
|
||||
if frame.arbitration_id == 0x4BB and len(data) >= 49:
|
||||
# The vehicle differential capture maps B41..B44 to the four outlet
|
||||
# vertical position signatures. 0x26 was observed only after CLOSE
|
||||
# and 0x80 after OPEN, independently for every outlet. B45..B48 are
|
||||
# retained as correlated horizontal evidence rather than interpreted
|
||||
# as a switch enum.
|
||||
names = (
|
||||
"driver_left",
|
||||
"driver_right",
|
||||
"passenger_left",
|
||||
"passenger_right",
|
||||
)
|
||||
switches = {}
|
||||
for index, name in enumerate(names):
|
||||
vertical_raw = int(data[41 + index])
|
||||
state = {0x80: "open", 0x26: "close"}.get(
|
||||
vertical_raw, "unknown"
|
||||
)
|
||||
switches[name] = {
|
||||
"state": state,
|
||||
"vertical_raw": vertical_raw,
|
||||
"horizontal_raw": int(data[45 + index]),
|
||||
"evidence": "0x4BB_position_signature",
|
||||
}
|
||||
decoded["domain_state"] = {"outlet": {"switches": switches}}
|
||||
return decoded
|
||||
|
||||
if frame.arbitration_id == 0x37B and len(data) >= 15 and data[0] == 0x50:
|
||||
# Matrix Sheet1 rows 8822/8823/8830/8831 expose direct individual
|
||||
# switch enums on platforms that route the 0x37B/0x50 variant.
|
||||
starts = {
|
||||
"driver_left": 64,
|
||||
"driver_right": 66,
|
||||
"passenger_left": 116,
|
||||
"passenger_right": 118,
|
||||
}
|
||||
decoded["domain_state"] = {
|
||||
"outlet": {
|
||||
"switches": {
|
||||
name: {
|
||||
"state": {1: "open", 2: "close"}.get(
|
||||
_bits(data, start, 2), "unknown"
|
||||
),
|
||||
"evidence": "0x37B_0x50_switch_enum",
|
||||
}
|
||||
for name, start in starts.items()
|
||||
}
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
|
||||
if frame.arbitration_id == 0x4FA and len(data) >= 8:
|
||||
thermal = {
|
||||
1: ("off", 0),
|
||||
@@ -462,6 +513,16 @@ def feedback_matches(expected: str, frame: CanFrame | None) -> bool | None:
|
||||
)
|
||||
return position is not None and abs(position - int(parts[2])) <= 3
|
||||
|
||||
if family == "outlet_switch":
|
||||
state = (
|
||||
decoded.get("domain_state", {})
|
||||
.get("outlet", {})
|
||||
.get("switches", {})
|
||||
.get(parts[1], {})
|
||||
.get("state")
|
||||
)
|
||||
return None if state is None else state == parts[2]
|
||||
|
||||
if family == "indoor_light":
|
||||
power = decoded.get("domain_state", {}).get("interior_light", {}).get("power")
|
||||
return power == parts[1]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -275,6 +275,7 @@ class NativeCanRelativeResolver:
|
||||
"target": target,
|
||||
"operation": "set",
|
||||
"parameters": parameters,
|
||||
"parse_source": "local_rule",
|
||||
"reason": f"用户要求{label}{verb}{delta};基于当前状态 {current} 计算绝对目标 {target_value}{',已按允许范围收敛' if clamped else ''}。",
|
||||
"relative_resolution": {
|
||||
"direction": direction,
|
||||
|
||||
@@ -17,6 +17,12 @@ from cognitive_agent.application.native_can_feedback import (
|
||||
decode_feedback,
|
||||
feedback_matches,
|
||||
)
|
||||
from cognitive_agent.generated.can_semantic_protocol import (
|
||||
CAN1_SUPPORTED_CAPABILITIES,
|
||||
PROTOCOL_VERSION,
|
||||
frame as shared_protocol_frame,
|
||||
payload_variant as shared_payload_variant,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
|
||||
|
||||
|
||||
@@ -86,7 +92,7 @@ class NativeCanRuntime:
|
||||
"""Compile named capabilities into byte-exact CAN_DEV frames."""
|
||||
|
||||
compiler_version = "1.0.0"
|
||||
mapping_version = "can-dev-verified-body-can-v5"
|
||||
mapping_version = f"shared-can-semantic-protocol-{PROTOCOL_VERSION}"
|
||||
|
||||
def __init__(self, repository_root: Path):
|
||||
self.root = repository_root
|
||||
@@ -111,30 +117,7 @@ class NativeCanRuntime:
|
||||
|
||||
@property
|
||||
def supported_capabilities(self) -> frozenset[str]:
|
||||
return frozenset(
|
||||
{
|
||||
"window.set_motion",
|
||||
"window.set_position",
|
||||
"climate.set_power",
|
||||
"climate.set_ac",
|
||||
"climate.set_fan_level",
|
||||
"climate.set_outlet_mode",
|
||||
"climate.set_temperature",
|
||||
"climate.set_control_mode",
|
||||
"climate.set_cycle_mode",
|
||||
"door.set_rear_lock",
|
||||
"outlet.set_position_percent",
|
||||
"outlet.set_calibrated_position",
|
||||
*DRIVER_SEAT_CAPABILITIES,
|
||||
"interior_light.set_indoor_power",
|
||||
"interior_light.set_reading_power",
|
||||
"ambient_light.set_power",
|
||||
"ambient_light.set_color_index",
|
||||
"ambient_light.set_brightness_level",
|
||||
"roof.set_shade_motion",
|
||||
"roof.set_shade_position",
|
||||
}
|
||||
)
|
||||
return CAN1_SUPPORTED_CAPABILITIES
|
||||
|
||||
def supports(self, action: dict[str, Any]) -> bool:
|
||||
capability = str(action.get("capability"))
|
||||
@@ -183,6 +166,9 @@ class NativeCanRuntime:
|
||||
"zone": target,
|
||||
},
|
||||
"parameters": dict(action.get("parameters", {})),
|
||||
"parse_source": str(
|
||||
action.get("parse_source", "local_rule")
|
||||
),
|
||||
"timeout_ms": (
|
||||
20_000
|
||||
if capability_name
|
||||
@@ -383,6 +369,8 @@ class NativeCanRuntime:
|
||||
return f"ac cycle {parameters['mode']}"
|
||||
if capability == "door.set_rear_lock":
|
||||
return f"door rear {parameters['state']}"
|
||||
if capability == "outlet.set_switch_state":
|
||||
return f"outlet {target} {parameters['state']}"
|
||||
if capability == "outlet.set_position_percent":
|
||||
return (
|
||||
f"outlet {target} percent {parameters['horizontal_pct']} "
|
||||
@@ -417,6 +405,8 @@ class NativeCanRuntime:
|
||||
return f"seat driver massage strength {strength}"
|
||||
return f"seat driver massage mode {parameters['mode']}"
|
||||
if capability == "seat.set_driver_axis_native":
|
||||
if parameters["direction"] == "stop":
|
||||
return f"seat driver {parameters['axis']} stop"
|
||||
return (
|
||||
f"seat driver {parameters['axis']} {parameters['direction']} "
|
||||
f"{parameters.get('duration_ms', 500)}"
|
||||
@@ -448,7 +438,18 @@ class NativeCanRuntime:
|
||||
started_at: str,
|
||||
mode: str,
|
||||
) -> dict[str, Any]:
|
||||
succeeded = all(item["status"] == "succeeded" for item in results)
|
||||
verification_statuses = [
|
||||
str(item.get("telemetry", {}).get("verification_status") or "")
|
||||
for item in results
|
||||
]
|
||||
verification_status = (
|
||||
"failed_or_blocked"
|
||||
if any(value == "failed_or_blocked" for value in verification_statuses)
|
||||
else "submitted_unverified"
|
||||
if any(value == "submitted_unverified" for value in verification_statuses)
|
||||
else "verified_success"
|
||||
)
|
||||
succeeded = verification_status != "failed_or_blocked"
|
||||
request_id = function_config["metadata"]["request_id"]
|
||||
domain = function_config["spec"]["domain"]
|
||||
return {
|
||||
@@ -467,6 +468,7 @@ class NativeCanRuntime:
|
||||
"spec": {
|
||||
"mode": mode,
|
||||
"status": "succeeded" if succeeded else "failed",
|
||||
"verification_status": verification_status,
|
||||
"started_at": started_at,
|
||||
"finished_at": _now(),
|
||||
"results": results,
|
||||
@@ -489,13 +491,10 @@ class NativeCanRuntime:
|
||||
payload[0] = 0x0A
|
||||
_set_bits(payload, 14, 2, state)
|
||||
return [
|
||||
self._classic(
|
||||
0x32B,
|
||||
self._shared_frame(
|
||||
"can1.door.rear_lock",
|
||||
payload,
|
||||
1,
|
||||
0,
|
||||
(0x407,),
|
||||
f"rear_lock:{parameters['state']}",
|
||||
expected=f"rear_lock:{parameters['state']}",
|
||||
)
|
||||
]
|
||||
|
||||
@@ -503,12 +502,18 @@ class NativeCanRuntime:
|
||||
state = self._enum(parameters, "state", {"on": 1, "off": 2})
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, 28, 2, state)
|
||||
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_power:{parameters['state']}")]
|
||||
return [self._shared_frame(
|
||||
"can1.climate.power",
|
||||
payload,
|
||||
expected=f"climate_power:{parameters['state']}",
|
||||
)]
|
||||
if capability == "climate.set_fan_level":
|
||||
level = self._integer(parameters, "level", 1, 7)
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, 4, 4, level)
|
||||
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_fan:{level}")]
|
||||
return [self._shared_frame(
|
||||
"can1.climate.fan", payload, expected=f"climate_fan:{level}"
|
||||
)]
|
||||
if capability == "climate.set_outlet_mode":
|
||||
mode = self._enum(
|
||||
parameters,
|
||||
@@ -525,25 +530,28 @@ class NativeCanRuntime:
|
||||
)
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, 0, 4, mode)
|
||||
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_mode:{mode}")]
|
||||
return [self._shared_frame(
|
||||
"can1.climate.mode", payload, expected=f"climate_mode:{mode}"
|
||||
)]
|
||||
if capability == "climate.set_control_mode":
|
||||
mode = self._enum(parameters, "mode", {"manual": 1, "auto": 2})
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, 16, 2, mode)
|
||||
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_control:{mode}")]
|
||||
return [self._shared_frame(
|
||||
"can1.climate.control",
|
||||
payload,
|
||||
expected=f"climate_control:{mode}",
|
||||
)]
|
||||
if capability == "climate.set_cycle_mode":
|
||||
self._reject_unexpected_parameters(parameters, {"mode"})
|
||||
mode = self._enum(parameters, "mode", {"internal": 1, "external": 2})
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, 19, 2, mode)
|
||||
return [
|
||||
self._classic(
|
||||
0x1DE,
|
||||
self._shared_frame(
|
||||
"can1.climate.cycle",
|
||||
payload,
|
||||
3,
|
||||
100,
|
||||
(0x404,),
|
||||
f"climate_cycle:{parameters['mode']}",
|
||||
expected=f"climate_cycle:{parameters['mode']}",
|
||||
)
|
||||
]
|
||||
if capability == "climate.set_temperature":
|
||||
@@ -568,13 +576,11 @@ class NativeCanRuntime:
|
||||
_set_bits(payload, start, 8, temperature)
|
||||
label = "driver" if start == 32 else "passenger" if start == 40 else "rear"
|
||||
specs.append(
|
||||
self._classic(
|
||||
0x1DE,
|
||||
self._shared_frame(
|
||||
"can1.climate.temperature",
|
||||
payload,
|
||||
1 if start == 48 else 3,
|
||||
100,
|
||||
(0x404,),
|
||||
f"climate_temperature:{label}:{temperature}",
|
||||
key="rear" if start == 48 else "front",
|
||||
expected=f"climate_temperature:{label}:{temperature}",
|
||||
)
|
||||
)
|
||||
return specs
|
||||
@@ -582,19 +588,20 @@ class NativeCanRuntime:
|
||||
if capability == "window.set_motion":
|
||||
motion = self._enum(parameters, "motion", {"open": 1, "close": 2, "stop": 3})
|
||||
payload = self._window_payload(target, motion)
|
||||
return [self._fd(0x431, payload, 3, 100, (0x388,), f"window_motion:{target}:{parameters['motion']}")]
|
||||
return [self._shared_frame(
|
||||
"can1.window.motion",
|
||||
payload,
|
||||
expected=f"window_motion:{target}:{parameters['motion']}",
|
||||
)]
|
||||
if capability == "window.set_position":
|
||||
position = self._integer(parameters, "position_pct", 0, 100)
|
||||
if target not in {"front_left", "rear_left", "front_right", "rear_right"}:
|
||||
raise NativeCanCompileError("absolute window position requires one window")
|
||||
return [
|
||||
NativeFrameSpec(
|
||||
frame_id=0x431,
|
||||
payload=self._window_payload(target, 3),
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
feedback_ids=(0x388,),
|
||||
expected_feedback=f"window_position:{target}:{position}",
|
||||
self._shared_frame(
|
||||
"can1.window.position",
|
||||
self._window_payload(target, 3),
|
||||
expected=f"window_position:{target}:{position}",
|
||||
special_handler="window_position",
|
||||
special_parameters={"target": target, "position_pct": position},
|
||||
)
|
||||
@@ -619,36 +626,56 @@ class NativeCanRuntime:
|
||||
self._require_driver_seat_target(target)
|
||||
return [self._driver_seat_memory_frame(parameters)]
|
||||
|
||||
if capability == "outlet.set_switch_state":
|
||||
return [self._outlet_switch_frame(target, parameters)]
|
||||
if capability in {"outlet.set_position_percent", "outlet.set_calibrated_position"}:
|
||||
return [self._outlet_frame(capability, target, parameters)]
|
||||
|
||||
if capability == "interior_light.set_indoor_power":
|
||||
return [self._all_reading_light_frame(parameters, "indoor_light")]
|
||||
return [
|
||||
self._all_reading_light_frame(
|
||||
parameters, "indoor_light", "can1.light.indoor"
|
||||
)
|
||||
]
|
||||
if capability == "interior_light.set_reading_power":
|
||||
if target != "all":
|
||||
raise NativeCanCompileError(
|
||||
"only the all-reading-lights target is verified on CH1"
|
||||
)
|
||||
return [self._all_reading_light_frame(parameters, "indoor_light")]
|
||||
return [
|
||||
self._all_reading_light_frame(
|
||||
parameters, "indoor_light", "can1.light.reading_all"
|
||||
)
|
||||
]
|
||||
|
||||
if capability == "ambient_light.set_power":
|
||||
self._reject_unexpected_parameters(parameters, {"state"})
|
||||
state = str(parameters.get("state"))
|
||||
self._enum(parameters, "state", {"off": 0, "on": 1})
|
||||
payload = bytes(
|
||||
(0x00, 0x36 if state == "on" else 0x31, 0x00, 0x75, 0x00, 0x80, 0x00, 0x00)
|
||||
)
|
||||
return [self._fd(0x3FC, payload, 3, 100, (0x42E,), f"ambient_power:{state}")]
|
||||
payload = shared_payload_variant("can1.light.ambient_power", state)
|
||||
return [self._shared_frame(
|
||||
"can1.light.ambient_power",
|
||||
payload,
|
||||
expected=f"ambient_power:{state}",
|
||||
)]
|
||||
if capability == "ambient_light.set_color_index":
|
||||
self._reject_unexpected_parameters(parameters, {"color_index"})
|
||||
color = self._integer(parameters, "color_index", 1, 127)
|
||||
payload = bytes((color, 0x30, 0x00, 0x75, 0x00, 0x00, 0x00, 0x00))
|
||||
return [self._fd(0x3FC, payload, 3, 100, (0x42E,), f"ambient_color:{color}")]
|
||||
return [self._shared_frame(
|
||||
"can1.light.ambient_color",
|
||||
payload,
|
||||
expected=f"ambient_color:{color}",
|
||||
)]
|
||||
if capability == "ambient_light.set_brightness_level":
|
||||
self._reject_unexpected_parameters(parameters, {"brightness_level"})
|
||||
level = self._integer(parameters, "brightness_level", 1, 5)
|
||||
payload = bytes((0x00, 0x31 + level, 0x00, 0x75, 0x00, 0x00, 0x00, 0x00))
|
||||
return [self._fd(0x3FC, payload, 3, 100, (0x42E,), f"ambient_brightness:{level}")]
|
||||
return [self._shared_frame(
|
||||
"can1.light.ambient_brightness",
|
||||
payload,
|
||||
expected=f"ambient_brightness:{level}",
|
||||
)]
|
||||
|
||||
if capability == "roof.set_shade_motion":
|
||||
motion = self._enum(parameters, "motion", {"open": 1, "close": 2, "half": 3, "stop": 4})
|
||||
@@ -663,41 +690,45 @@ class NativeCanRuntime:
|
||||
raise NativeCanCompileError(f"CAN_DEV does not implement {capability}")
|
||||
|
||||
@staticmethod
|
||||
def _classic(
|
||||
frame_id: int,
|
||||
def _shared_frame(
|
||||
rule_id: str,
|
||||
payload: bytes | bytearray,
|
||||
repeat: int = 1,
|
||||
period_ms: int = 0,
|
||||
feedback_ids: tuple[int, ...] = (),
|
||||
*,
|
||||
key: str = "command",
|
||||
expected: str = "",
|
||||
special_handler: str = "",
|
||||
special_parameters: dict[str, Any] | None = None,
|
||||
repeat_count: int | None = None,
|
||||
) -> NativeFrameSpec:
|
||||
return NativeFrameSpec(
|
||||
frame_id,
|
||||
bytes(payload),
|
||||
repeat_count=repeat,
|
||||
period_ms=period_ms,
|
||||
feedback_ids=feedback_ids,
|
||||
expected_feedback=expected,
|
||||
)
|
||||
"""Build a frame using transport metadata generated from the shared manifest."""
|
||||
|
||||
@staticmethod
|
||||
def _fd(
|
||||
frame_id: int,
|
||||
payload: bytes | bytearray,
|
||||
repeat: int = 1,
|
||||
period_ms: int = 0,
|
||||
feedback_ids: tuple[int, ...] = (),
|
||||
expected: str = "",
|
||||
) -> NativeFrameSpec:
|
||||
metadata = shared_protocol_frame(rule_id, key)
|
||||
encoded = bytes(payload)
|
||||
dlc = int(metadata["dlc"])
|
||||
if len(encoded) != dlc:
|
||||
raise NativeCanCompileError(
|
||||
f"{rule_id}/{key} payload must contain {dlc} bytes"
|
||||
)
|
||||
repeat = metadata["repeat"]
|
||||
if repeat_count is None:
|
||||
repeat_count = (
|
||||
int(repeat["count"])
|
||||
if repeat["kind"] == "fixed"
|
||||
else 1
|
||||
)
|
||||
return NativeFrameSpec(
|
||||
frame_id,
|
||||
bytes(payload),
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
repeat_count=repeat,
|
||||
period_ms=period_ms,
|
||||
feedback_ids=feedback_ids,
|
||||
frame_id=int(str(metadata["frame_id"]), 16),
|
||||
payload=encoded,
|
||||
is_fd=metadata["format"] == "can_fd",
|
||||
bitrate_switch=bool(metadata["brs"]),
|
||||
repeat_count=repeat_count,
|
||||
period_ms=int(repeat["period_ms"]),
|
||||
feedback_ids=tuple(
|
||||
int(str(value), 16) for value in metadata["feedback_ids"]
|
||||
),
|
||||
expected_feedback=expected,
|
||||
special_handler=special_handler,
|
||||
special_parameters=special_parameters,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -786,13 +817,53 @@ class NativeCanRuntime:
|
||||
payload[7] = 0xFF
|
||||
_set_bits(payload, horizontal_start, 8, horizontal + 128)
|
||||
_set_bits(payload, vertical_start, 8, vertical + 128)
|
||||
return self._fd(
|
||||
0x4EF,
|
||||
return self._shared_frame(
|
||||
(
|
||||
"can1.outlet.percent"
|
||||
if capability == "outlet.set_position_percent"
|
||||
else "can1.outlet.position"
|
||||
),
|
||||
payload,
|
||||
3,
|
||||
100,
|
||||
(0x2F5, 0x37B),
|
||||
f"outlet:{target}:{horizontal}:{vertical}",
|
||||
expected=f"outlet:{target}:{horizontal}:{vertical}",
|
||||
)
|
||||
|
||||
def _outlet_switch_frame(
|
||||
self, target: str, parameters: dict[str, Any]
|
||||
) -> NativeFrameSpec:
|
||||
"""Compile the four independently observed front-outlet switch fields."""
|
||||
|
||||
self._reject_unexpected_parameters(parameters, {"state"})
|
||||
state = str(parameters.get("state"))
|
||||
raw = self._enum(parameters, "state", {"open": 1, "close": 2})
|
||||
layouts = {
|
||||
# Keep every untouched byte exactly as emitted by the head unit.
|
||||
# In particular, passenger B6/B7 are 00 00 rather than FF FF.
|
||||
"driver_left": (0x02, 0, 0xFF, 0xFF),
|
||||
"driver_right": (0x02, 2, 0xFF, 0xFF),
|
||||
"passenger_left": (0x03, 0, 0x00, 0x00),
|
||||
"passenger_right": (0x03, 2, 0x00, 0x00),
|
||||
}
|
||||
try:
|
||||
sub_id, shift, byte6, byte7 = layouts[target]
|
||||
except KeyError as exc:
|
||||
raise NativeCanCompileError(
|
||||
f"unsupported outlet switch target {target}"
|
||||
) from exc
|
||||
payload = shared_payload_variant(
|
||||
"can1.outlet.switch", f"{target.replace('_', '-')}:{state}"
|
||||
)
|
||||
# Keep the table above as a decoder/layout guard, but take the exact
|
||||
# observed request bytes from the generated shared protocol.
|
||||
if payload != bytes(
|
||||
(sub_id, 0xFF, 0xFF, 0xFF, 0xFF, raw << shift, byte6, byte7)
|
||||
):
|
||||
raise NativeCanCompileError(
|
||||
f"generated outlet switch payload disagrees with layout for {target}"
|
||||
)
|
||||
return self._shared_frame(
|
||||
"can1.outlet.switch",
|
||||
payload,
|
||||
expected=f"outlet_switch:{target}:{state}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -840,13 +911,10 @@ class NativeCanRuntime:
|
||||
payload[1] = (
|
||||
control_value << 4 if feature == "heating" else control_value
|
||||
)
|
||||
return self._fd(
|
||||
0x431,
|
||||
return self._shared_frame(
|
||||
"can1.seat.driver_thermal",
|
||||
payload,
|
||||
3,
|
||||
100,
|
||||
(0x4FA,),
|
||||
f"driver_seat_{feature}:{feedback_value}",
|
||||
expected=f"driver_seat_{feature}:{feedback_value}",
|
||||
)
|
||||
|
||||
def _driver_seat_massage_frame(
|
||||
@@ -881,7 +949,9 @@ class NativeCanRuntime:
|
||||
expected = f"driver_seat_massage_mode:{mode}"
|
||||
else:
|
||||
raise NativeCanCompileError("action must be off, strength, or mode")
|
||||
return self._fd(0x4C1, payload, 3, 100, (0x4FA,), expected)
|
||||
return self._shared_frame(
|
||||
"can1.seat.driver_massage", payload, expected=expected
|
||||
)
|
||||
|
||||
def _driver_seat_axis_frame(
|
||||
self, parameters: dict[str, Any]
|
||||
@@ -924,29 +994,35 @@ class NativeCanRuntime:
|
||||
_set_bits(payload, starts[axis], 2, allowed_directions[direction])
|
||||
expected = f"driver_seat_axis:{axis}:{direction}"
|
||||
if direction == "stop":
|
||||
return self._fd(
|
||||
0x4C1, stop_payload, 3, 100, (0x494,), expected
|
||||
return self._shared_frame(
|
||||
"can1.seat.driver_axis",
|
||||
stop_payload,
|
||||
key="stop",
|
||||
expected=expected,
|
||||
)
|
||||
|
||||
repeat_count = (duration_ms + 99) // 100
|
||||
return NativeFrameSpec(
|
||||
frame_id=0x4C1,
|
||||
payload=bytes(payload),
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
period_ms = int(
|
||||
shared_protocol_frame("can1.seat.driver_axis", "motion")["repeat"][
|
||||
"period_ms"
|
||||
]
|
||||
)
|
||||
repeat_count = (duration_ms + period_ms - 1) // period_ms
|
||||
stop_metadata = shared_protocol_frame("can1.seat.driver_axis", "stop")
|
||||
return self._shared_frame(
|
||||
"can1.seat.driver_axis",
|
||||
payload,
|
||||
key="motion",
|
||||
expected=expected,
|
||||
repeat_count=repeat_count,
|
||||
period_ms=100,
|
||||
feedback_ids=(0x494,),
|
||||
expected_feedback=expected,
|
||||
special_handler="driver_seat_axis",
|
||||
special_parameters={
|
||||
"duration_ms": duration_ms,
|
||||
"final_action_hold_ms": 100,
|
||||
"final_action_hold_ms": period_ms,
|
||||
"stop_data_hex": " ".join(
|
||||
f"{value:02X}" for value in stop_payload
|
||||
),
|
||||
"stop_repeat_count": 3,
|
||||
"stop_period_ms": 100,
|
||||
"stop_repeat_count": int(stop_metadata["repeat"]["count"]),
|
||||
"stop_period_ms": int(stop_metadata["repeat"]["period_ms"]),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1000,42 +1076,48 @@ class NativeCanRuntime:
|
||||
|
||||
payload_15 = learned_payload("payload_15", 0x15)
|
||||
payload_16 = learned_payload("payload_16", 0x16)
|
||||
sequence_metadata = shared_protocol_frame(
|
||||
"can1.seat.driver_memory", "sequence"
|
||||
)
|
||||
sequence_count = int(sequence_metadata["repeat"]["count"])
|
||||
if sequence_count % 2:
|
||||
raise NativeCanCompileError(
|
||||
"driver memory sequence count must contain complete 0x16/0x15 pairs"
|
||||
)
|
||||
sequence = [
|
||||
value.hex(" ").upper()
|
||||
for _ in range(10)
|
||||
for _ in range(sequence_count // 2)
|
||||
for value in (payload_16, payload_15)
|
||||
]
|
||||
return NativeFrameSpec(
|
||||
frame_id=0x4C1,
|
||||
payload=payload_16,
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
feedback_ids=(0x494,),
|
||||
expected_feedback=f"driver_seat_memory_status:{slot}",
|
||||
return self._shared_frame(
|
||||
"can1.seat.driver_memory",
|
||||
payload_16,
|
||||
key="sequence",
|
||||
expected=f"driver_seat_memory_status:{slot}",
|
||||
special_handler="driver_seat_memory_recall",
|
||||
special_parameters={
|
||||
"slot": slot,
|
||||
"fresh_status_timeout_ms": 2_500,
|
||||
"post_status_timeout_ms": 1_200,
|
||||
"period_ms": 50,
|
||||
"period_ms": int(sequence_metadata["repeat"]["period_ms"]),
|
||||
"frame_sequence": sequence,
|
||||
},
|
||||
)
|
||||
|
||||
def _all_reading_light_frame(
|
||||
self, parameters: dict[str, Any], feedback_family: str
|
||||
self,
|
||||
parameters: dict[str, Any],
|
||||
feedback_family: str,
|
||||
rule_id: str,
|
||||
) -> NativeFrameSpec:
|
||||
self._reject_unexpected_parameters(parameters, {"state"})
|
||||
state = self._enum(parameters, "state", {"off": 1, "on": 2})
|
||||
payload = bytearray((0, 0, 3, 0xFF, 0xFF, 0, 0, 0))
|
||||
_set_bits(payload, 50, 2, state)
|
||||
return self._fd(
|
||||
0x4F5,
|
||||
return self._shared_frame(
|
||||
rule_id,
|
||||
payload,
|
||||
3,
|
||||
100,
|
||||
(0x42E,),
|
||||
f"{feedback_family}:{parameters['state']}",
|
||||
expected=f"{feedback_family}:{parameters['state']}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -1047,11 +1129,16 @@ class NativeCanRuntime:
|
||||
def _shade_preset_frame(self, motion: int) -> NativeFrameSpec:
|
||||
target = {1: 100, 2: 0, 3: 50}.get(motion)
|
||||
expected = "shade_stopped" if motion == 4 else f"shade_position:{target}"
|
||||
return NativeFrameSpec(
|
||||
frame_id=0x431,
|
||||
payload=self._shade_payload(motion),
|
||||
feedback_ids=(0x41A,),
|
||||
expected_feedback=expected,
|
||||
selector = {1: "open", 2: "close", 3: "half", 4: "stop"}[motion]
|
||||
payload = shared_payload_variant("can1.shade.motion", selector)
|
||||
if payload != self._shade_payload(motion):
|
||||
raise NativeCanCompileError(
|
||||
f"generated shade payload disagrees with motion {motion}"
|
||||
)
|
||||
return self._shared_frame(
|
||||
"can1.shade.motion",
|
||||
payload,
|
||||
expected=expected,
|
||||
special_handler="shade_preset",
|
||||
special_parameters={
|
||||
"motion": motion,
|
||||
@@ -1062,20 +1149,25 @@ class NativeCanRuntime:
|
||||
)
|
||||
|
||||
def _shade_position_frame(self, position: int) -> NativeFrameSpec:
|
||||
return NativeFrameSpec(
|
||||
frame_id=0x431,
|
||||
payload=self._shade_payload(4),
|
||||
feedback_ids=(0x41A,),
|
||||
expected_feedback=f"shade_position:{position}",
|
||||
return self._shared_frame(
|
||||
"can1.shade.position",
|
||||
self._shade_payload(4),
|
||||
expected=f"shade_position:{position}",
|
||||
special_handler="shade_position",
|
||||
special_parameters={
|
||||
"position_pct": position,
|
||||
"fresh_feedback_timeout_ms": 2_500,
|
||||
"motion_timeout_ms": 15_000,
|
||||
"tolerance_pct": 2,
|
||||
"open_data_hex": "10 00 00 00 00 00 00 00",
|
||||
"close_data_hex": "20 00 00 00 00 00 00 00",
|
||||
"stop_data_hex": "40 00 00 00 00 00 00 00",
|
||||
"open_data_hex": shared_payload_variant(
|
||||
"can1.shade.motion", "open"
|
||||
).hex(" ").upper(),
|
||||
"close_data_hex": shared_payload_variant(
|
||||
"can1.shade.motion", "close"
|
||||
).hex(" ").upper(),
|
||||
"stop_data_hex": shared_payload_variant(
|
||||
"can1.shade.motion", "stop"
|
||||
).hex(" ").upper(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1129,15 +1221,30 @@ class NativeCanExecutor:
|
||||
"feedback ID and a target-value match contract"
|
||||
)
|
||||
special_handler = command["signals"].get("special_handler")
|
||||
if special_handler == "window_position":
|
||||
feedback_required = self.feedback_required
|
||||
if not feedback_required and special_handler in {
|
||||
None,
|
||||
"shade_preset",
|
||||
}:
|
||||
# Wireless MQTT uses the state topic as an asynchronous
|
||||
# source of truth. Fixed frames and self-terminating
|
||||
# shade presets must not hold the HTTP request open
|
||||
# while waiting for the same state stream synchronously.
|
||||
sent_frames = self._send_command(bus, command)
|
||||
feedback = None
|
||||
elif special_handler == "window_position":
|
||||
sent_frames, feedback = self._execute_window_position(bus, command)
|
||||
elif special_handler == "driver_seat_axis":
|
||||
sent_frames, feedback = self._execute_driver_seat_axis(
|
||||
bus, command
|
||||
bus,
|
||||
command,
|
||||
wait_for_feedback=feedback_required,
|
||||
)
|
||||
elif special_handler == "driver_seat_memory_recall":
|
||||
sent_frames, feedback = self._execute_driver_seat_memory(
|
||||
bus, command
|
||||
bus,
|
||||
command,
|
||||
wait_for_completion=feedback_required,
|
||||
)
|
||||
elif special_handler == "shade_preset":
|
||||
sent_frames, feedback = self._execute_shade_preset(
|
||||
@@ -1151,10 +1258,21 @@ class NativeCanExecutor:
|
||||
sent_frames = self._send_command(bus, command)
|
||||
feedback = self._wait_feedback(bus, command)
|
||||
feedback_match = self._feedback_matches(command, feedback)
|
||||
feedback_required = self.feedback_required
|
||||
verified = feedback is not None and feedback_match is True
|
||||
delivered = bool(sent_frames)
|
||||
succeeded = verified or (delivered and not feedback_required)
|
||||
# The operation-level status describes whether the approved
|
||||
# command was submitted. Physical completion remains a
|
||||
# separate evidence level in ``verification_status`` and
|
||||
# ``physical_effect_confirmed``. A feedback timeout must
|
||||
# not be rewritten as a transport/orchestration failure.
|
||||
succeeded = delivered
|
||||
verification_status = (
|
||||
"verified_success"
|
||||
if verified
|
||||
else "submitted_unverified"
|
||||
if delivered
|
||||
else "failed_or_blocked"
|
||||
)
|
||||
physical_channel = (
|
||||
feedback.channel
|
||||
if feedback is not None
|
||||
@@ -1167,17 +1285,26 @@ class NativeCanExecutor:
|
||||
publication_start:
|
||||
]
|
||||
)
|
||||
delivery_status = "submitted"
|
||||
if not feedback_required and delivered:
|
||||
delivery_status = (
|
||||
"published"
|
||||
if mqtt_messages
|
||||
and all(
|
||||
message.get("delivery_status") == "published"
|
||||
for message in mqtt_messages
|
||||
)
|
||||
else "queued"
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"ref": operation["id"],
|
||||
"domain": function_config["spec"]["domain"],
|
||||
"status": "succeeded" if succeeded else "failed",
|
||||
"message": (
|
||||
"MQTT 白名单控制帧已发布,并收到匹配的车辆状态反馈。"
|
||||
if not feedback_required and feedback_match is True
|
||||
else "MQTT 白名单控制帧已发布;已收到状态帧,但目标值尚未确认。"
|
||||
if not feedback_required and feedback is not None
|
||||
else "MQTT 白名单控制帧已发布;车辆状态反馈尚未确认。"
|
||||
"MQTT 白名单控制帧已由 Broker 确认;车辆效果以状态话题回读为准。"
|
||||
if not feedback_required and delivery_status == "published"
|
||||
else "MQTT 白名单控制帧已进入客户端发布队列;车辆效果以状态话题回读为准。"
|
||||
if not feedback_required
|
||||
else "CAN 盒已提交车端控制帧并观察到匹配反馈。"
|
||||
if feedback_match is True
|
||||
@@ -1197,11 +1324,8 @@ class NativeCanExecutor:
|
||||
"feedback_match": feedback_match,
|
||||
"feedback_state": self._feedback_state(feedback),
|
||||
"feedback_required": feedback_required,
|
||||
"delivery_status": (
|
||||
"published"
|
||||
if not feedback_required and delivered
|
||||
else "submitted"
|
||||
),
|
||||
"delivery_status": delivery_status,
|
||||
"verification_status": verification_status,
|
||||
"physical_effect_confirmed": verified,
|
||||
"control_gate": (
|
||||
"compiled_action_and_frame_whitelist"
|
||||
@@ -1230,6 +1354,7 @@ class NativeCanExecutor:
|
||||
"requested_parameters": operation["parameters"],
|
||||
"command_frame": runtime.command_frame_public(command),
|
||||
"channel": 1,
|
||||
"verification_status": "failed_or_blocked",
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
@@ -1260,7 +1385,11 @@ class NativeCanExecutor:
|
||||
return sent
|
||||
|
||||
def _execute_driver_seat_axis(
|
||||
self, bus: CanBus, command: dict[str, Any]
|
||||
self,
|
||||
bus: CanBus,
|
||||
command: dict[str, Any],
|
||||
*,
|
||||
wait_for_feedback: bool = True,
|
||||
) -> tuple[list[CanFrame], CanFrame | None]:
|
||||
"""Send bounded motion and attempt all three stop frames in a finally path."""
|
||||
|
||||
@@ -1313,10 +1442,14 @@ class NativeCanExecutor:
|
||||
f"{len(stop_errors)}/3 forced-stop frame(s) failed"
|
||||
)
|
||||
raise RuntimeError("; ".join(details))
|
||||
return sent, self._wait_feedback(bus, command)
|
||||
return sent, self._wait_feedback(bus, command) if wait_for_feedback else None
|
||||
|
||||
def _execute_driver_seat_memory(
|
||||
self, bus: CanBus, command: dict[str, Any]
|
||||
self,
|
||||
bus: CanBus,
|
||||
command: dict[str, Any],
|
||||
*,
|
||||
wait_for_completion: bool = True,
|
||||
) -> tuple[list[CanFrame], CanFrame | None]:
|
||||
"""Require fresh initialization, then replay the learned 16/15 sequence."""
|
||||
|
||||
@@ -1366,8 +1499,12 @@ class NativeCanExecutor:
|
||||
)
|
||||
if index + 1 < len(sequence):
|
||||
time.sleep(int(special["period_ms"]) / 1_000)
|
||||
feedback = self._wait_for_id(
|
||||
bus, 0x494, int(special["post_status_timeout_ms"])
|
||||
feedback = (
|
||||
self._wait_for_id(
|
||||
bus, 0x494, int(special["post_status_timeout_ms"])
|
||||
)
|
||||
if wait_for_completion
|
||||
else None
|
||||
)
|
||||
return sent, feedback
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,22 @@ FUZZY_UNMAPPED_CONTROL = re.compile(
|
||||
r"(?P<text>(?:座椅|靠背|腿托|空调|车窗|风口|灯光?)"
|
||||
r"(?:调|弄|搞)(?:得|到)?(?:舒服|合适|好)(?:一点|点|些)?)"
|
||||
)
|
||||
OUTLET_MODE_DISABLE = re.compile(
|
||||
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?"
|
||||
r"(?P<feature>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?|"
|
||||
r"(?P<feature_after>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?"
|
||||
r"(?:关闭|关掉|停止|取消)"
|
||||
)
|
||||
POSITIVE_OUTLET_MODE = re.compile(
|
||||
r"(?:打开|开启|启用|切换到|切换为|改为|设为|设置为|调整为|调为)"
|
||||
r"(?:空调)?(?:的)?(?:吹面|吹脸|吹脚|除霜)"
|
||||
)
|
||||
DRIVER_SEAT_THERMAL_MIDDLE = re.compile(
|
||||
r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}"
|
||||
r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
|
||||
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
|
||||
r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
|
||||
)
|
||||
|
||||
|
||||
def _span(utterance: str, match: re.Match[str]) -> dict[str, Any]:
|
||||
@@ -114,6 +130,21 @@ def _remove_seat_target(parsed: dict[str, Any], target: str) -> None:
|
||||
]
|
||||
|
||||
|
||||
def _remove_capability(parsed: dict[str, Any], capability: str) -> None:
|
||||
for key in (
|
||||
"actions",
|
||||
"domain_actions",
|
||||
"native_can_actions",
|
||||
"deferred_requests",
|
||||
"model_requests",
|
||||
):
|
||||
parsed[key] = [
|
||||
request
|
||||
for request in parsed.get(key, [])
|
||||
if request.get("capability") != capability
|
||||
]
|
||||
|
||||
|
||||
def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict[str, Any]:
|
||||
"""Preserve negation and unresolved control language without producing CAN."""
|
||||
|
||||
@@ -162,6 +193,100 @@ def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict
|
||||
]
|
||||
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
|
||||
|
||||
disabled_outlets = list(OUTLET_MODE_DISABLE.finditer(utterance))
|
||||
if disabled_outlets and POSITIVE_OUTLET_MODE.search(utterance) is None:
|
||||
_remove_capability(result, "climate.set_outlet_mode")
|
||||
for match in disabled_outlets:
|
||||
feature = match.group("feature") or match.group("feature_after")
|
||||
constraints.append(
|
||||
{
|
||||
"type": "prohibit_action",
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_outlet_mode",
|
||||
"parameters": {"disabled_feature": feature},
|
||||
"source_span": _span(utterance, match),
|
||||
}
|
||||
)
|
||||
unresolved.append(
|
||||
{
|
||||
"text": match.group(0),
|
||||
"reason": "outlet_feature_disable_requires_absolute_mode",
|
||||
}
|
||||
)
|
||||
result["ambiguities"] = [
|
||||
*result.get("ambiguities", []),
|
||||
{
|
||||
"field": "climate.outlet_mode",
|
||||
"question": (
|
||||
"当前CAN白名单只能设置完整出风模式;请明确改为吹脚、除霜等目标模式。"
|
||||
),
|
||||
"reason": "outlet_feature_disable_requires_absolute_mode",
|
||||
},
|
||||
]
|
||||
result["decision_summary"] = [
|
||||
*result.get("decision_summary", []),
|
||||
"检测到关闭单一出风方向的请求;未将其错误转换为开启该出风模式。",
|
||||
]
|
||||
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
|
||||
|
||||
thermal_middle = DRIVER_SEAT_THERMAL_MIDDLE.search(utterance)
|
||||
if thermal_middle is not None and not re.search(
|
||||
r"副驾|副驾驶|乘客|右前", utterance
|
||||
):
|
||||
feature = "通风" if "通风" in thermal_middle.group(0) else "加热"
|
||||
native_capability = (
|
||||
"seat.set_driver_ventilation_native"
|
||||
if feature == "通风"
|
||||
else "seat.set_driver_heating_native"
|
||||
)
|
||||
canonical_capability = (
|
||||
"seat.set_ventilation"
|
||||
if feature == "通风"
|
||||
else "seat.set_heating"
|
||||
)
|
||||
_remove_capability(result, native_capability)
|
||||
_remove_capability(result, canonical_capability)
|
||||
constraint = {
|
||||
"type": "unsupported_discrete_level",
|
||||
"domain": "seat",
|
||||
"capability": native_capability,
|
||||
"parameters": {"requested_level": "medium"},
|
||||
"source_span": _span(utterance, thermal_middle),
|
||||
}
|
||||
if not any(
|
||||
item.get("type") == constraint["type"]
|
||||
and item.get("capability") == native_capability
|
||||
and item.get("parameters") == constraint["parameters"]
|
||||
for item in constraints
|
||||
):
|
||||
constraints.append(constraint)
|
||||
unresolved.append(
|
||||
{
|
||||
"text": thermal_middle.group(0),
|
||||
"reason": "driver_seat_thermal_middle_level_unsupported",
|
||||
}
|
||||
)
|
||||
result["ambiguities"] = [
|
||||
*result.get("ambiguities", []),
|
||||
{
|
||||
"field": (
|
||||
"seat.driver.ventilation.level"
|
||||
if feature == "通风"
|
||||
else "seat.driver.heating.level"
|
||||
),
|
||||
"question": (
|
||||
f"主驾座椅{feature}实车白名单只有低档和高档,"
|
||||
"请明确选择低档或高档。"
|
||||
),
|
||||
"reason": "driver_seat_thermal_middle_level_unsupported",
|
||||
},
|
||||
]
|
||||
result["decision_summary"] = [
|
||||
*result.get("decision_summary", []),
|
||||
f"识别到主驾座椅{feature}中档请求;实车协议不支持中档,未降级为其他档位。",
|
||||
]
|
||||
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
|
||||
|
||||
if not result.get("actions") and not result.get("domain_actions"):
|
||||
for match in FUZZY_UNMAPPED_CONTROL.finditer(utterance):
|
||||
if not any(item.get("text") == match.group("text") for item in unresolved):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Generated protocol modules. Regenerate them from their checked-in sources."""
|
||||
@@ -0,0 +1,864 @@
|
||||
# Generated by tools/generate_can_semantic_protocol.py; do not edit.
|
||||
"""Generated Python view of the shared CAN semantic protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
PROTOCOL_SOURCE_SHA256 = "cfb7a7eb28d14a82da70d44efece7f045a7a87a4cd549d7537dc00438c297e6e"
|
||||
PROTOCOL_MANIFEST: dict[str, Any] = {'schema': 'cognitive-agent-can-semantic-protocol/v1',
|
||||
'version': '1.0.0',
|
||||
'description': 'Single source of truth for CAN0/CAN1 semantic commands and transport metadata. '
|
||||
'Frame encoders and feedback algorithms remain named special handlers when they '
|
||||
'require stateful closed-loop behavior.',
|
||||
'channels': {'0': {'name': 'passenger_seat_can', 'frame_format': 'classic_can', 'bitrate': 500000},
|
||||
'1': {'name': 'vehicle_body_can',
|
||||
'frame_format': 'mixed',
|
||||
'arbitration_bitrate': 500000,
|
||||
'data_bitrate': 2000000}},
|
||||
'rules': [{'id': 'can0.seat.position',
|
||||
'channel': 0,
|
||||
'domain': 'seat',
|
||||
'capabilities': ['seat.set_longitudinal_position',
|
||||
'seat.set_cushion_tilt',
|
||||
'seat.set_backrest_recline',
|
||||
'seat.set_legrest_height'],
|
||||
'help': 'seat passenger <horizontal|basin|backrest|legrest> position <0-100>',
|
||||
'patterns': [[{'literal': 'seat'},
|
||||
{'literal': 'passenger'},
|
||||
{'enum': 'axis',
|
||||
'values': ['horizontal',
|
||||
'longitudinal',
|
||||
'basin',
|
||||
'height',
|
||||
'backrest',
|
||||
'legrest']},
|
||||
{'literal': 'position'},
|
||||
{'integer': 'position_pct', 'minimum': 0, 'maximum': 100}]],
|
||||
'frames': [{'key': 'move',
|
||||
'frame_id': '0x300',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'can0_motion_fields',
|
||||
'base_payload_hex': '00 10 00 00 00 00 00 00',
|
||||
'fields': {'opcode': {'start_bit': 0,
|
||||
'bit_length': 8,
|
||||
'absolute': 1,
|
||||
'home_zero': 11},
|
||||
'motor': {'start_bit': 8,
|
||||
'bit_length': 2,
|
||||
'values': {'horizontal': 0,
|
||||
'longitudinal': 0,
|
||||
'basin': 1,
|
||||
'height': 1,
|
||||
'backrest': 2,
|
||||
'legrest': 3}},
|
||||
'protocol_version': {'start_bit': 12,
|
||||
'bit_length': 2,
|
||||
'constant': 1},
|
||||
'sequence': {'start_bit': 16,
|
||||
'bit_length': 16,
|
||||
'runtime_allocated': True},
|
||||
'param_a': {'start_bit': 32,
|
||||
'bit_length': 16,
|
||||
'scale': 100,
|
||||
'home_key': 19282},
|
||||
'param_b': {'start_bit': 48,
|
||||
'bit_length': 16,
|
||||
'absolute_timeout_ms': 30000,
|
||||
'home_timeout_s': 120}},
|
||||
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
|
||||
'feedback_ids': ['0x301', '0x302'],
|
||||
'feedback_policy': 'ack_then_motor_terminal'}],
|
||||
'special_handler': 'can0_absolute_position',
|
||||
'evidence': 'passenger_firmware_hil_verified'},
|
||||
{'id': 'can0.seat.relative',
|
||||
'channel': 0,
|
||||
'domain': 'seat',
|
||||
'capabilities': ['seat.set_longitudinal_position',
|
||||
'seat.set_cushion_tilt',
|
||||
'seat.set_backrest_recline',
|
||||
'seat.set_legrest_height'],
|
||||
'help': 'seat passenger <axis> relative <direction> <1-100>',
|
||||
'patterns': [[{'literal': 'seat'},
|
||||
{'literal': 'passenger'},
|
||||
{'enum': 'axis', 'values': ['horizontal', 'longitudinal']},
|
||||
{'literal': 'relative'},
|
||||
{'enum': 'direction', 'values': ['forward', 'backward']},
|
||||
{'integer': 'delta_pct', 'minimum': 1, 'maximum': 100}],
|
||||
[{'literal': 'seat'},
|
||||
{'literal': 'passenger'},
|
||||
{'enum': 'axis', 'values': ['basin', 'height']},
|
||||
{'literal': 'relative'},
|
||||
{'enum': 'direction', 'values': ['up', 'down']},
|
||||
{'integer': 'delta_pct', 'minimum': 1, 'maximum': 100}],
|
||||
[{'literal': 'seat'},
|
||||
{'literal': 'passenger'},
|
||||
{'literal': 'backrest'},
|
||||
{'literal': 'relative'},
|
||||
{'enum': 'direction', 'values': ['forward', 'backward']},
|
||||
{'integer': 'delta_pct', 'minimum': 1, 'maximum': 100}],
|
||||
[{'literal': 'seat'},
|
||||
{'literal': 'passenger'},
|
||||
{'literal': 'legrest'},
|
||||
{'literal': 'relative'},
|
||||
{'enum': 'direction', 'values': ['up', 'down']},
|
||||
{'integer': 'delta_pct', 'minimum': 1, 'maximum': 100}]],
|
||||
'frames': [{'key': 'move',
|
||||
'frame_id': '0x300',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'can0_relative_closed_loop_or_timed_fallback',
|
||||
'base_payload_hex': '03 10 00 00 00 00 00 00',
|
||||
'fields': {'closed_loop_opcode': 1,
|
||||
'timed_fallback_opcode': 3,
|
||||
'timed_fallback_max_delta_pct': 15,
|
||||
'direction_values': {'increase': 1, 'decrease': 2}},
|
||||
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
|
||||
'feedback_ids': ['0x301', '0x302'],
|
||||
'feedback_policy': 'fresh_position_then_ack_then_motor_terminal'}],
|
||||
'special_handler': 'can0_relative_position',
|
||||
'evidence': 'passenger_firmware_hil_verified'},
|
||||
{'id': 'can0.seat.thermal',
|
||||
'channel': 0,
|
||||
'domain': 'seat',
|
||||
'capabilities': ['seat.set_ventilation', 'seat.set_heating'],
|
||||
'help': 'seat passenger <vent|heat> <off|low|medium|high>',
|
||||
'patterns': [[{'literal': 'seat'},
|
||||
{'literal': 'passenger'},
|
||||
{'enum': 'feature', 'values': ['vent', 'heat']},
|
||||
{'enum': 'level', 'values': ['off', 'low', 'medium', 'high']}]],
|
||||
'frames': [{'key': 'thermal_mode',
|
||||
'frame_id': '0x3CE',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'can0_heating_mode_optional',
|
||||
'base_payload_hex': '10 00 00 00 00 00 00 00',
|
||||
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
|
||||
'feedback_ids': ['0x4FA'],
|
||||
'optional_when': 'heating_on'},
|
||||
{'key': 'thermal_control',
|
||||
'frame_id': '0x29E',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'can0_thermal_bit_fields',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
|
||||
'feedback_ids': ['0x4FA'],
|
||||
'feedback_policy': 'thermal_state_match'}],
|
||||
'special_handler': 'can0_thermal_preserve_peer_feature',
|
||||
'evidence': 'passenger_firmware_hil_verified'},
|
||||
{'id': 'can0.seat.dynamic',
|
||||
'channel': 0,
|
||||
'domain': 'seat',
|
||||
'capabilities': ['seat.start_dynamic_mode', 'seat.stop_dynamic_mode'],
|
||||
'help': 'seat passenger dynamic <rocking-horse|cradle|stop>',
|
||||
'patterns': [[{'literal': 'seat'},
|
||||
{'literal': 'passenger'},
|
||||
{'literal': 'dynamic'},
|
||||
{'enum': 'mode', 'values': ['rocking-horse', 'cradle', 'stop']}],
|
||||
[{'literal': 'seat'},
|
||||
{'literal': 'passenger'},
|
||||
{'literal': 'dynamic'},
|
||||
{'literal': 'start'},
|
||||
{'enum': 'mode', 'values': ['rocking-horse', 'cradle']}]],
|
||||
'frames': [{'key': 'dynamic',
|
||||
'frame_id': '0x300',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'can0_dynamic_fields',
|
||||
'base_payload_hex': '0E 10 00 00 00 00 00 00',
|
||||
'fields': {'start_opcode': 14,
|
||||
'stop_opcode': 15,
|
||||
'mode_values': {'rocking-horse': 1, 'cradle': 2, 'stop': 0}},
|
||||
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
|
||||
'feedback_ids': ['0x301', '0x306'],
|
||||
'feedback_policy': 'ack_then_dynamic_state'}],
|
||||
'special_handler': 'can0_dynamic_mode',
|
||||
'evidence': 'passenger_firmware_hil_verified'},
|
||||
{'id': 'can1.door.rear_lock',
|
||||
'channel': 1,
|
||||
'domain': 'door',
|
||||
'capabilities': ['door.set_rear_lock'],
|
||||
'help': 'door rear <lock|unlock>',
|
||||
'patterns': [[{'literal': 'door'},
|
||||
{'literal': 'rear'},
|
||||
{'enum': 'state', 'values': ['lock', 'unlock']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x32B',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'bit_fields',
|
||||
'base_payload_hex': '0A 00 00 00 00 00 00 00',
|
||||
'fields': {'state': {'start_bit': 14,
|
||||
'bit_length': 2,
|
||||
'values': {'lock': 1, 'unlock': 2}}},
|
||||
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
|
||||
'feedback_ids': ['0x407'],
|
||||
'feedback_policy': 'rear_lock_state_match'}],
|
||||
'evidence': 'matrix_candidate_with_feedback'},
|
||||
{'id': 'can1.window.motion',
|
||||
'channel': 1,
|
||||
'domain': 'window',
|
||||
'capabilities': ['window.set_motion'],
|
||||
'help': 'window <all|front|rear|front-left|front-right|rear-left|rear-right> '
|
||||
'<open|close|stop>',
|
||||
'patterns': [[{'literal': 'window'},
|
||||
{'enum': 'target',
|
||||
'values': ['all',
|
||||
'front',
|
||||
'rear',
|
||||
'front-left',
|
||||
'front-right',
|
||||
'rear-left',
|
||||
'rear-right']},
|
||||
{'enum': 'motion', 'values': ['open', 'close', 'stop']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x431',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'window_motion_bit_fields',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'fields': {'starts': [48, 56, 51, 59],
|
||||
'motion_values': {'open': 1, 'close': 2, 'stop': 3}},
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x388'],
|
||||
'feedback_policy': 'window_state_match'}],
|
||||
'evidence': 'vehicle_trace_verified'},
|
||||
{'id': 'can1.window.position',
|
||||
'channel': 1,
|
||||
'domain': 'window',
|
||||
'capabilities': ['window.set_position'],
|
||||
'help': 'window <front-left|front-right|rear-left|rear-right> position <0-100>',
|
||||
'patterns': [[{'literal': 'window'},
|
||||
{'enum': 'target',
|
||||
'values': ['front-left', 'front-right', 'rear-left', 'rear-right']},
|
||||
{'literal': 'position'},
|
||||
{'integer': 'position_pct', 'minimum': 0, 'maximum': 100}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x431',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'window_feedback_closed_loop',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'repeat': {'kind': 'special', 'count': 1, 'period_ms': 0},
|
||||
'feedback_ids': ['0x388'],
|
||||
'feedback_policy': 'fresh_position_then_motion_then_stop'}],
|
||||
'special_handler': 'window_position'},
|
||||
{'id': 'can1.climate.power',
|
||||
'channel': 1,
|
||||
'domain': 'climate',
|
||||
'capabilities': ['climate.set_power', 'climate.set_ac'],
|
||||
'help': 'ac <on|off>',
|
||||
'patterns': [[{'literal': 'ac'}, {'enum': 'state', 'values': ['on', 'off']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x1DE',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'bit_fields',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'fields': {'state': {'start_bit': 28,
|
||||
'bit_length': 2,
|
||||
'values': {'on': 1, 'off': 2}}},
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x404'],
|
||||
'feedback_policy': 'climate_power_match'}],
|
||||
'evidence': 'matrix_and_vehicle_trace_verified'},
|
||||
{'id': 'can1.climate.fan',
|
||||
'channel': 1,
|
||||
'domain': 'climate',
|
||||
'capabilities': ['climate.set_fan_level'],
|
||||
'help': 'ac fan <1-7>',
|
||||
'patterns': [[{'literal': 'ac'},
|
||||
{'literal': 'fan'},
|
||||
{'integer': 'level', 'minimum': 1, 'maximum': 7}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x1DE',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'bit_fields',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'fields': {'level': {'start_bit': 4, 'bit_length': 4}},
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x404'],
|
||||
'feedback_policy': 'climate_fan_match'}],
|
||||
'evidence': 'matrix_and_vehicle_trace_verified'},
|
||||
{'id': 'can1.climate.mode',
|
||||
'channel': 1,
|
||||
'domain': 'climate',
|
||||
'capabilities': ['climate.set_outlet_mode'],
|
||||
'help': 'ac mode '
|
||||
'<face|face-feet|feet|feet-defrost|defrost|face-feet-defrost|face-defrost>',
|
||||
'patterns': [[{'literal': 'ac'},
|
||||
{'literal': 'mode'},
|
||||
{'enum': 'mode',
|
||||
'values': ['face',
|
||||
'face-feet',
|
||||
'feet',
|
||||
'feet-defrost',
|
||||
'defrost',
|
||||
'face-feet-defrost',
|
||||
'face-defrost']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x1DE',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'bit_fields',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'fields': {'mode': {'start_bit': 0,
|
||||
'bit_length': 4,
|
||||
'values': {'face': 1,
|
||||
'face-feet': 2,
|
||||
'feet': 3,
|
||||
'feet-defrost': 4,
|
||||
'defrost': 5,
|
||||
'face-feet-defrost': 6,
|
||||
'face-defrost': 7}}},
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x404'],
|
||||
'feedback_policy': 'climate_mode_match'}],
|
||||
'evidence': 'matrix_and_vehicle_trace_verified'},
|
||||
{'id': 'can1.climate.temperature',
|
||||
'channel': 1,
|
||||
'domain': 'climate',
|
||||
'capabilities': ['climate.set_temperature'],
|
||||
'help': 'ac temp <driver|passenger|rear> <18-32>',
|
||||
'patterns': [[{'literal': 'ac'},
|
||||
{'literal': 'temp'},
|
||||
{'enum': 'target', 'values': ['driver', 'passenger', 'rear']},
|
||||
{'integer': 'temperature_c', 'minimum': 18, 'maximum': 32}]],
|
||||
'frames': [{'key': 'front',
|
||||
'frame_id': '0x1DE',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'climate_temperature',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'fields': {'starts': {'driver': 32, 'passenger': 40}},
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x404'],
|
||||
'feedback_policy': 'climate_temperature_match'},
|
||||
{'key': 'rear',
|
||||
'frame_id': '0x1DE',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'climate_temperature',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'fields': {'starts': {'rear': 48}},
|
||||
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 100},
|
||||
'feedback_ids': ['0x404'],
|
||||
'feedback_policy': 'climate_temperature_match'}],
|
||||
'evidence': 'matrix_and_vehicle_trace_verified'},
|
||||
{'id': 'can1.climate.control',
|
||||
'channel': 1,
|
||||
'domain': 'climate',
|
||||
'capabilities': ['climate.set_control_mode'],
|
||||
'help': 'ac control <manual|auto>',
|
||||
'patterns': [[{'literal': 'ac'},
|
||||
{'literal': 'control'},
|
||||
{'enum': 'mode', 'values': ['manual', 'auto']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x1DE',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'bit_fields',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'fields': {'mode': {'start_bit': 16,
|
||||
'bit_length': 2,
|
||||
'values': {'manual': 1, 'auto': 2}}},
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x404'],
|
||||
'feedback_policy': 'climate_control_match'}],
|
||||
'evidence': 'matrix_and_vehicle_trace_verified'},
|
||||
{'id': 'can1.climate.cycle',
|
||||
'channel': 1,
|
||||
'domain': 'climate',
|
||||
'capabilities': ['climate.set_cycle_mode'],
|
||||
'help': 'ac cycle <internal|external>',
|
||||
'patterns': [[{'literal': 'ac'},
|
||||
{'literal': 'cycle'},
|
||||
{'enum': 'mode', 'values': ['internal', 'external']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x1DE',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'bit_fields',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'fields': {'mode': {'start_bit': 19,
|
||||
'bit_length': 2,
|
||||
'values': {'internal': 1, 'external': 2}}},
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x404'],
|
||||
'feedback_policy': 'climate_cycle_match'}],
|
||||
'evidence': 'matrix_and_vehicle_trace_verified'},
|
||||
{'id': 'can1.outlet.switch',
|
||||
'channel': 1,
|
||||
'domain': 'outlet',
|
||||
'capabilities': ['outlet.set_switch_state'],
|
||||
'help': 'outlet <driver-left|driver-right|passenger-left|passenger-right> <open|close>',
|
||||
'patterns': [[{'literal': 'outlet'},
|
||||
{'enum': 'target',
|
||||
'values': ['driver-left',
|
||||
'driver-right',
|
||||
'passenger-left',
|
||||
'passenger-right']},
|
||||
{'enum': 'state', 'values': ['open', 'close']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x4EF',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'static_variant',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'payload_variants': {'driver-left:open': '02 FF FF FF FF 01 FF FF',
|
||||
'driver-left:close': '02 FF FF FF FF 02 FF FF',
|
||||
'driver-right:open': '02 FF FF FF FF 04 FF FF',
|
||||
'driver-right:close': '02 FF FF FF FF 08 FF FF',
|
||||
'passenger-left:open': '03 FF FF FF FF 01 00 00',
|
||||
'passenger-left:close': '03 FF FF FF FF 02 00 00',
|
||||
'passenger-right:open': '03 FF FF FF FF 04 00 00',
|
||||
'passenger-right:close': '03 FF FF FF FF 08 00 00'},
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x4BB', '0x37B'],
|
||||
'feedback_policy': 'outlet_switch_state_match'}],
|
||||
'evidence': 'vehicle_differential_trace_verified'},
|
||||
{'id': 'can1.outlet.percent',
|
||||
'channel': 1,
|
||||
'domain': 'outlet',
|
||||
'capabilities': ['outlet.set_position_percent'],
|
||||
'help': 'outlet <target> percent <0-100> <0-100>',
|
||||
'patterns': [[{'literal': 'outlet'},
|
||||
{'enum': 'target',
|
||||
'values': ['driver-left',
|
||||
'driver-right',
|
||||
'passenger-left',
|
||||
'passenger-right']},
|
||||
{'literal': 'percent'},
|
||||
{'integer': 'horizontal_pct', 'minimum': 0, 'maximum': 100},
|
||||
{'integer': 'vertical_pct', 'minimum': 0, 'maximum': 100}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x4EF',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'outlet_calibrated_coordinates',
|
||||
'base_payload_hex': '00 FF FF FF FF 00 00 00',
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x2F5', '0x37B'],
|
||||
'feedback_policy': 'outlet_position_match'}],
|
||||
'special_handler': 'outlet_percent_to_calibrated_position'},
|
||||
{'id': 'can1.outlet.position',
|
||||
'channel': 1,
|
||||
'domain': 'outlet',
|
||||
'capabilities': ['outlet.set_calibrated_position'],
|
||||
'help': 'outlet <target> position <-128..126> <-128..126>',
|
||||
'patterns': [[{'literal': 'outlet'},
|
||||
{'enum': 'target',
|
||||
'values': ['driver-left',
|
||||
'driver-right',
|
||||
'passenger-left',
|
||||
'passenger-right']},
|
||||
{'literal': 'position'},
|
||||
{'integer': 'horizontal_value', 'minimum': -128, 'maximum': 126},
|
||||
{'integer': 'vertical_value', 'minimum': -128, 'maximum': 126}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x4EF',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'outlet_calibrated_coordinates',
|
||||
'base_payload_hex': '00 FF FF FF FF 00 00 00',
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x2F5', '0x37B'],
|
||||
'feedback_policy': 'outlet_position_match'}],
|
||||
'special_handler': 'outlet_calibrated_position'},
|
||||
{'id': 'can1.seat.driver_thermal',
|
||||
'channel': 1,
|
||||
'domain': 'seat',
|
||||
'capabilities': ['seat.set_driver_ventilation_native',
|
||||
'seat.set_driver_heating_native'],
|
||||
'help': 'seat driver <vent|heat> <off|low|high>',
|
||||
'patterns': [[{'literal': 'seat'},
|
||||
{'literal': 'driver'},
|
||||
{'enum': 'feature', 'values': ['vent', 'heat']},
|
||||
{'enum': 'level', 'values': ['off', 'low', 'high']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x431',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'driver_seat_thermal',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x4FA'],
|
||||
'feedback_policy': 'driver_seat_thermal_match'}],
|
||||
'evidence': 'vehicle_trace_verified'},
|
||||
{'id': 'can1.seat.driver_massage',
|
||||
'channel': 1,
|
||||
'domain': 'seat',
|
||||
'capabilities': ['seat.set_driver_massage_native'],
|
||||
'help': 'seat driver massage <off|strength weak|medium|strong|mode '
|
||||
'wave|pulse|stretch|slack|relax>',
|
||||
'patterns': [[{'literal': 'seat'},
|
||||
{'literal': 'driver'},
|
||||
{'literal': 'massage'},
|
||||
{'literal': 'off'}],
|
||||
[{'literal': 'seat'},
|
||||
{'literal': 'driver'},
|
||||
{'literal': 'massage'},
|
||||
{'literal': 'strength'},
|
||||
{'enum': 'strength', 'values': ['weak', 'medium', 'strong']}],
|
||||
[{'literal': 'seat'},
|
||||
{'literal': 'driver'},
|
||||
{'literal': 'massage'},
|
||||
{'literal': 'mode'},
|
||||
{'enum': 'mode',
|
||||
'values': ['wave', 'pulse', 'stretch', 'slack', 'relax']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x4C1',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'driver_seat_massage',
|
||||
'base_payload_hex': '12 00 00 00 00 00 00 00',
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x4FA'],
|
||||
'feedback_policy': 'driver_seat_massage_match'}],
|
||||
'evidence': 'vehicle_trace_verified'},
|
||||
{'id': 'can1.seat.driver_axis',
|
||||
'channel': 1,
|
||||
'domain': 'seat',
|
||||
'capabilities': ['seat.set_driver_axis_native'],
|
||||
'help': 'seat driver <horizontal|height|backrest|basin> <direction|stop> [100-2000]',
|
||||
'patterns': [[{'literal': 'seat'},
|
||||
{'literal': 'driver'},
|
||||
{'enum': 'axis', 'values': ['horizontal', 'backrest']},
|
||||
{'enum': 'direction', 'values': ['forward', 'backward']}],
|
||||
[{'literal': 'seat'},
|
||||
{'literal': 'driver'},
|
||||
{'enum': 'axis', 'values': ['horizontal', 'backrest']},
|
||||
{'enum': 'direction', 'values': ['forward', 'backward']},
|
||||
{'integer': 'duration_ms', 'minimum': 100, 'maximum': 2000}],
|
||||
[{'literal': 'seat'},
|
||||
{'literal': 'driver'},
|
||||
{'enum': 'axis', 'values': ['height', 'basin']},
|
||||
{'enum': 'direction', 'values': ['up', 'down']}],
|
||||
[{'literal': 'seat'},
|
||||
{'literal': 'driver'},
|
||||
{'enum': 'axis', 'values': ['height', 'basin']},
|
||||
{'enum': 'direction', 'values': ['up', 'down']},
|
||||
{'integer': 'duration_ms', 'minimum': 100, 'maximum': 2000}],
|
||||
[{'literal': 'seat'},
|
||||
{'literal': 'driver'},
|
||||
{'enum': 'axis', 'values': ['horizontal', 'height', 'backrest', 'basin']},
|
||||
{'literal': 'stop'}]],
|
||||
'frames': [{'key': 'motion',
|
||||
'frame_id': '0x4C1',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'driver_seat_axis',
|
||||
'base_payload_hex': '05 00 00 00 00 00 00 00',
|
||||
'repeat': {'kind': 'duration_ceiling',
|
||||
'count': 0,
|
||||
'period_ms': 100,
|
||||
'default_duration_ms': 500},
|
||||
'feedback_ids': ['0x494'],
|
||||
'feedback_policy': 'activity_then_forced_stop'},
|
||||
{'key': 'stop',
|
||||
'frame_id': '0x4C1',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'static',
|
||||
'base_payload_hex': '05 00 00 00 00 00 00 00',
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x494'],
|
||||
'feedback_policy': 'submitted_stop'}],
|
||||
'special_handler': 'driver_seat_axis'},
|
||||
{'id': 'can1.seat.driver_memory',
|
||||
'channel': 1,
|
||||
'domain': 'seat',
|
||||
'capabilities': ['seat.set_driver_position_native', 'seat.recall_driver_memory_native'],
|
||||
'help': 'seat driver position <1-3> | seat driver memory recall <1-3>',
|
||||
'patterns': [[{'literal': 'seat'},
|
||||
{'literal': 'driver'},
|
||||
{'literal': 'position'},
|
||||
{'integer': 'slot', 'minimum': 1, 'maximum': 3}],
|
||||
[{'literal': 'seat'},
|
||||
{'literal': 'driver'},
|
||||
{'literal': 'memory'},
|
||||
{'literal': 'recall'},
|
||||
{'integer': 'slot', 'minimum': 1, 'maximum': 3}]],
|
||||
'frames': [{'key': 'sequence',
|
||||
'frame_id': '0x4C1',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'learned_driver_memory_pair',
|
||||
'base_payload_hex': '16 00 00 00 00 00 00 00',
|
||||
'repeat': {'kind': 'sequence', 'count': 20, 'period_ms': 50},
|
||||
'feedback_ids': ['0x494'],
|
||||
'feedback_policy': 'fresh_initialized_status_then_post_status'}],
|
||||
'special_handler': 'driver_seat_memory_recall'},
|
||||
{'id': 'can1.light.indoor',
|
||||
'channel': 1,
|
||||
'domain': 'light',
|
||||
'capabilities': ['interior_light.set_indoor_power'],
|
||||
'help': 'light indoor <on|off>',
|
||||
'patterns': [[{'literal': 'light'},
|
||||
{'literal': 'indoor'},
|
||||
{'enum': 'state', 'values': ['on', 'off']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x4F5',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'reading_light_all',
|
||||
'base_payload_hex': '00 00 03 FF FF 00 00 00',
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x42E'],
|
||||
'feedback_policy': 'indoor_light_match'}],
|
||||
'evidence': 'vehicle_trace_verified'},
|
||||
{'id': 'can1.light.reading_all',
|
||||
'channel': 1,
|
||||
'domain': 'light',
|
||||
'capabilities': ['interior_light.set_reading_power'],
|
||||
'help': 'light reading all <on|off>',
|
||||
'patterns': [[{'literal': 'light'},
|
||||
{'literal': 'reading'},
|
||||
{'literal': 'all'},
|
||||
{'enum': 'state', 'values': ['on', 'off']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x4F5',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'reading_light_all',
|
||||
'base_payload_hex': '00 00 03 FF FF 00 00 00',
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x42E'],
|
||||
'feedback_policy': 'indoor_light_match'}],
|
||||
'evidence': 'vehicle_trace_verified'},
|
||||
{'id': 'can1.light.ambient_power',
|
||||
'channel': 1,
|
||||
'domain': 'light',
|
||||
'capabilities': ['ambient_light.set_power'],
|
||||
'help': 'light ambient <on|off>',
|
||||
'patterns': [[{'literal': 'light'},
|
||||
{'literal': 'ambient'},
|
||||
{'enum': 'state', 'values': ['on', 'off']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x3FC',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'static_variant',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'payload_variants': {'on': '00 36 00 75 00 80 00 00',
|
||||
'off': '00 31 00 75 00 80 00 00'},
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x42E'],
|
||||
'feedback_policy': 'ambient_power_match'}],
|
||||
'evidence': 'vehicle_trace_verified'},
|
||||
{'id': 'can1.light.ambient_color',
|
||||
'channel': 1,
|
||||
'domain': 'light',
|
||||
'capabilities': ['ambient_light.set_color_index'],
|
||||
'help': 'light ambient color <1-127>',
|
||||
'patterns': [[{'literal': 'light'},
|
||||
{'literal': 'ambient'},
|
||||
{'literal': 'color'},
|
||||
{'integer': 'color_index', 'minimum': 1, 'maximum': 127}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x3FC',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'ambient_color',
|
||||
'base_payload_hex': '00 30 00 75 00 00 00 00',
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x42E'],
|
||||
'feedback_policy': 'ambient_color_match'}],
|
||||
'evidence': 'vehicle_trace_verified'},
|
||||
{'id': 'can1.light.ambient_brightness',
|
||||
'channel': 1,
|
||||
'domain': 'light',
|
||||
'capabilities': ['ambient_light.set_brightness_level'],
|
||||
'help': 'light ambient brightness <1-5>',
|
||||
'patterns': [[{'literal': 'light'},
|
||||
{'literal': 'ambient'},
|
||||
{'literal': 'brightness'},
|
||||
{'integer': 'brightness_level', 'minimum': 1, 'maximum': 5}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x3FC',
|
||||
'format': 'can_fd',
|
||||
'brs': True,
|
||||
'dlc': 8,
|
||||
'template_kind': 'ambient_brightness',
|
||||
'base_payload_hex': '00 30 00 75 00 00 00 00',
|
||||
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
|
||||
'feedback_ids': ['0x42E'],
|
||||
'feedback_policy': 'ambient_brightness_match'}],
|
||||
'evidence': 'vehicle_trace_verified'},
|
||||
{'id': 'can1.shade.motion',
|
||||
'channel': 1,
|
||||
'domain': 'roof',
|
||||
'capabilities': ['roof.set_shade_motion'],
|
||||
'help': 'shade <open|close|half|stop>',
|
||||
'patterns': [[{'literal': 'shade'},
|
||||
{'enum': 'motion', 'values': ['open', 'close', 'half', 'stop']}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x431',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'static_variant',
|
||||
'base_payload_hex': '00 00 00 00 00 00 00 00',
|
||||
'payload_variants': {'open': '10 00 00 00 00 00 00 00',
|
||||
'close': '20 00 00 00 00 00 00 00',
|
||||
'half': '30 00 00 00 00 00 00 00',
|
||||
'stop': '40 00 00 00 00 00 00 00'},
|
||||
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
|
||||
'feedback_ids': ['0x41A'],
|
||||
'feedback_policy': 'shade_position_or_stop_submission'}],
|
||||
'special_handler': 'shade_preset'},
|
||||
{'id': 'can1.shade.position',
|
||||
'channel': 1,
|
||||
'domain': 'roof',
|
||||
'capabilities': ['roof.set_shade_position'],
|
||||
'help': 'shade position <0-100>',
|
||||
'patterns': [[{'literal': 'shade'},
|
||||
{'literal': 'position'},
|
||||
{'integer': 'position_pct', 'minimum': 0, 'maximum': 100}]],
|
||||
'frames': [{'key': 'command',
|
||||
'frame_id': '0x431',
|
||||
'format': 'classic_can',
|
||||
'brs': False,
|
||||
'dlc': 8,
|
||||
'template_kind': 'shade_feedback_closed_loop',
|
||||
'base_payload_hex': '40 00 00 00 00 00 00 00',
|
||||
'repeat': {'kind': 'special', 'count': 1, 'period_ms': 0},
|
||||
'feedback_ids': ['0x41A'],
|
||||
'feedback_policy': 'fresh_position_then_motion_then_stop'}],
|
||||
'special_handler': 'shade_position'}]}
|
||||
PROTOCOL_VERSION = str(PROTOCOL_MANIFEST["version"])
|
||||
RULES: tuple[dict[str, Any], ...] = tuple(PROTOCOL_MANIFEST["rules"])
|
||||
RULE_BY_ID = {str(rule["id"]): rule for rule in RULES}
|
||||
CAN0_SUPPORTED_CAPABILITIES = frozenset(
|
||||
capability
|
||||
for rule in RULES
|
||||
if int(rule["channel"]) == 0
|
||||
for capability in rule["capabilities"]
|
||||
)
|
||||
CAN1_SUPPORTED_CAPABILITIES = frozenset(
|
||||
capability
|
||||
for rule in RULES
|
||||
if int(rule["channel"]) == 1
|
||||
for capability in rule["capabilities"]
|
||||
)
|
||||
|
||||
|
||||
def rule(rule_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return RULE_BY_ID[rule_id]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"unknown generated CAN protocol rule {rule_id}") from exc
|
||||
|
||||
|
||||
def frame(rule_id: str, key: str = "command") -> dict[str, Any]:
|
||||
for item in rule(rule_id)["frames"]:
|
||||
if item["key"] == key:
|
||||
return item
|
||||
raise KeyError(f"rule {rule_id} has no frame named {key}")
|
||||
|
||||
|
||||
def command_contract(rule_id: str, key: str = "command") -> dict[str, Any]:
|
||||
metadata = frame(rule_id, key)
|
||||
return {
|
||||
"source_version": PROTOCOL_VERSION,
|
||||
"rule_id": rule_id,
|
||||
"frame_key": key,
|
||||
"frame_id": metadata["frame_id"],
|
||||
"format": metadata["format"],
|
||||
"brs": bool(metadata["brs"]),
|
||||
"dlc": int(metadata["dlc"]),
|
||||
"repeat": dict(metadata["repeat"]),
|
||||
"feedback_ids": list(metadata["feedback_ids"]),
|
||||
"feedback_policy": str(metadata.get("feedback_policy", "")),
|
||||
}
|
||||
|
||||
|
||||
def payload_variant(rule_id: str, selector: str, key: str = "command") -> bytes:
|
||||
item = frame(rule_id, key)
|
||||
try:
|
||||
value = item["payload_variants"][selector]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"rule {rule_id} frame {key} has no payload {selector}") from exc
|
||||
return bytes.fromhex(str(value))
|
||||
|
||||
|
||||
def rules_for_capability(capability: str, channel: int | None = None) -> tuple[dict[str, Any], ...]:
|
||||
return tuple(
|
||||
item
|
||||
for item in RULES
|
||||
if capability in item["capabilities"]
|
||||
and (channel is None or int(item["channel"]) == channel)
|
||||
)
|
||||
|
||||
|
||||
def _matches_token(word: str, token: dict[str, Any]) -> bool:
|
||||
if "literal" in token:
|
||||
return word == token["literal"]
|
||||
if "enum" in token:
|
||||
return word in token["values"]
|
||||
try:
|
||||
value = int(word, 10)
|
||||
except ValueError:
|
||||
return False
|
||||
return str(value) == word or (word.startswith("-") and str(value) == word)
|
||||
|
||||
|
||||
def _matches_integer(word: str, token: dict[str, Any]) -> bool:
|
||||
try:
|
||||
value = int(word, 10)
|
||||
except ValueError:
|
||||
return False
|
||||
return token["minimum"] <= value <= token["maximum"] and str(value) == word
|
||||
|
||||
|
||||
def match_semantic_command(words: str | Iterable[str], channel: int | None = None) -> dict[str, Any] | None:
|
||||
normalized = tuple(
|
||||
part.lower()
|
||||
for part in (words.split() if isinstance(words, str) else words)
|
||||
)
|
||||
for item in RULES:
|
||||
if channel is not None and int(item["channel"]) != channel:
|
||||
continue
|
||||
for pattern in item["patterns"]:
|
||||
if len(pattern) != len(normalized):
|
||||
continue
|
||||
if all(
|
||||
_matches_integer(word, token) if "integer" in token else _matches_token(word, token)
|
||||
for word, token in zip(normalized, pattern)
|
||||
):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def whitelist_help(channel: int) -> tuple[str, ...]:
|
||||
return tuple(str(item["help"]) for item in RULES if int(item["channel"]) == channel)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Configuration and fail-closed gates for the T2G seat hardware bench."""
|
||||
"""Configuration and fail-closed gates for the passenger-seat CAN0 bench."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -174,16 +174,39 @@ class SeatHardwareSettings:
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, repository_root: Path) -> "SeatHardwareSettings":
|
||||
hardware_enabled = _env_bool("COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED")
|
||||
legacy_hardware_enabled = _env_bool(
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED"
|
||||
)
|
||||
vehicle_backend = os.getenv(
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND", "zlgcan"
|
||||
).strip().lower()
|
||||
vehicle_enabled = _env_bool("COGNITIVE_AGENT_VEHICLE_CAN_ENABLED")
|
||||
# On the Linux N7 harness the same USBCANFD-200U owns both channels:
|
||||
# CAN0 is the latest passenger-seat firmware and CAN1 is body CAN FD.
|
||||
# Keep the old Windows/IAR bench opt-in intact, while making the paired
|
||||
# Linux CAN0 role follow an explicitly enabled wired vehicle adapter.
|
||||
wired_passenger_enabled = _env_bool(
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED",
|
||||
default=vehicle_enabled and vehicle_backend == "zlgcan-linux",
|
||||
)
|
||||
hardware_enabled = legacy_hardware_enabled or wired_passenger_enabled
|
||||
auto_flash_enabled = _env_bool(
|
||||
"COGNITIVE_AGENT_SEAT_AUTO_FLASH_ENABLED",
|
||||
default=hardware_enabled,
|
||||
default=legacy_hardware_enabled and not wired_passenger_enabled,
|
||||
)
|
||||
backend = os.getenv("COGNITIVE_AGENT_SEAT_CAN_BACKEND", "zlgcan").strip().lower()
|
||||
if backend not in {"zlgcan", "python-can"}:
|
||||
backend = os.getenv(
|
||||
"COGNITIVE_AGENT_SEAT_CAN_BACKEND",
|
||||
"zlgcan-linux" if wired_passenger_enabled else "zlgcan",
|
||||
).strip().lower()
|
||||
if backend not in {"zlgcan", "zlgcan-linux", "python-can"}:
|
||||
raise HardwareSafetyError(
|
||||
"COGNITIVE_AGENT_SEAT_CAN_BACKEND must be zlgcan or python-can"
|
||||
"COGNITIVE_AGENT_SEAT_CAN_BACKEND must be zlgcan, "
|
||||
"zlgcan-linux or python-can"
|
||||
)
|
||||
linux_vci = backend == "zlgcan-linux"
|
||||
default_linux_library = (
|
||||
repository_root.parent / "project-code" / "canfd" / "libusbcanfd.so"
|
||||
)
|
||||
firmware_root = (
|
||||
repository_root / "firmware" / "t2g" / "Seat_Controller_demo"
|
||||
)
|
||||
@@ -216,22 +239,31 @@ class SeatHardwareSettings:
|
||||
backend=backend,
|
||||
bitrate=_env_int("COGNITIVE_AGENT_SEAT_CAN_BITRATE", 500_000),
|
||||
termination_enabled=_env_bool(
|
||||
"COGNITIVE_AGENT_SEAT_CAN_TERMINATION_ENABLED", default=True
|
||||
"COGNITIVE_AGENT_SEAT_CAN_TERMINATION_ENABLED",
|
||||
default=False if linux_vci else True,
|
||||
),
|
||||
channel=os.getenv("COGNITIVE_AGENT_SEAT_CAN_CHANNEL", "0").strip(),
|
||||
interface=os.getenv("COGNITIVE_AGENT_SEAT_CAN_INTERFACE", "socketcan").strip(),
|
||||
device_type=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE", 41),
|
||||
device_type=_env_int(
|
||||
"COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE", 33 if linux_vci else 41
|
||||
),
|
||||
device_index=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_INDEX", 0),
|
||||
zlg_python=Path(
|
||||
os.getenv(
|
||||
"COGNITIVE_AGENT_ZLGCAN_PYTHON",
|
||||
r"C:\Program Files (x86)\ZCANPRO\python38\python.exe",
|
||||
sys.executable
|
||||
if linux_vci
|
||||
else r"C:\Program Files (x86)\ZCANPRO\python38\python.exe",
|
||||
)
|
||||
),
|
||||
zlg_dll=Path(
|
||||
os.getenv(
|
||||
"COGNITIVE_AGENT_ZLGCAN_DLL",
|
||||
r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll",
|
||||
"COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY"
|
||||
if linux_vci
|
||||
else "COGNITIVE_AGENT_ZLGCAN_DLL",
|
||||
str(default_linux_library)
|
||||
if linux_vci
|
||||
else r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll",
|
||||
)
|
||||
),
|
||||
bridge_script=repository_root
|
||||
@@ -239,7 +271,7 @@ class SeatHardwareSettings:
|
||||
/ "cognitive_agent"
|
||||
/ "infrastructure"
|
||||
/ "can"
|
||||
/ "zlgcan_bridge.py",
|
||||
/ ("zlgcan_linux_bridge.py" if linux_vci else "zlgcan_bridge.py"),
|
||||
cspybat=Path(
|
||||
os.getenv(
|
||||
"COGNITIVE_AGENT_IAR_CSPYBAT",
|
||||
@@ -250,7 +282,7 @@ class SeatHardwareSettings:
|
||||
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,
|
||||
default=legacy_hardware_enabled and not wired_passenger_enabled,
|
||||
),
|
||||
home_timeout_s=_env_int(
|
||||
"COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S", 120
|
||||
@@ -277,7 +309,7 @@ class SeatHardwareSettings:
|
||||
def adapter_configured(self) -> bool:
|
||||
if self.bitrate != 500_000:
|
||||
return False
|
||||
if self.backend == "zlgcan":
|
||||
if self.backend in {"zlgcan", "zlgcan-linux"}:
|
||||
return (
|
||||
self.zlg_python.is_file()
|
||||
and self.zlg_dll.is_file()
|
||||
@@ -285,8 +317,28 @@ class SeatHardwareSettings:
|
||||
)
|
||||
return bool(self.interface and self.channel)
|
||||
|
||||
@property
|
||||
def native_passenger_can0(self) -> bool:
|
||||
"""Whether CAN0 uses the latest Linux passenger-seat firmware protocol."""
|
||||
|
||||
if self.backend != "zlgcan-linux":
|
||||
return False
|
||||
try:
|
||||
return int(self.channel, 0) == 0
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
if self.native_passenger_can0:
|
||||
return bool(
|
||||
self.enabled
|
||||
and (not self.approval_required or self.approval_token)
|
||||
and self.adapter_configured
|
||||
and self.device_type == 33
|
||||
)
|
||||
if self.backend == "zlgcan-linux":
|
||||
return False
|
||||
return bool(
|
||||
self.enabled
|
||||
and (not self.approval_required or self.approval_token)
|
||||
@@ -308,7 +360,8 @@ class SeatHardwareSettings:
|
||||
def authorize(self, supplied_token: str | None) -> None:
|
||||
if not self.enabled:
|
||||
raise HardwareSafetyError(
|
||||
"hardware execution is disabled; set COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED=true"
|
||||
"passenger-seat hardware execution is disabled; set "
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED=true"
|
||||
)
|
||||
if self.approval_required:
|
||||
if not self.approval_token:
|
||||
@@ -319,6 +372,24 @@ class SeatHardwareSettings:
|
||||
supplied_token, self.approval_token
|
||||
):
|
||||
raise HardwareSafetyError("hardware approval token is invalid")
|
||||
if self.native_passenger_can0:
|
||||
if self.device_type != 33:
|
||||
raise HardwareSafetyError(
|
||||
"the Linux USBCANFD-200U passenger path requires device type 33"
|
||||
)
|
||||
if self.bitrate != 500_000:
|
||||
raise HardwareSafetyError(
|
||||
"the passenger CAN0 protocol requires 500000 bit/s"
|
||||
)
|
||||
if not self.adapter_configured:
|
||||
raise HardwareSafetyError(
|
||||
"the configured Linux ZLG CAN0 runtime is not ready"
|
||||
)
|
||||
return
|
||||
if self.backend == "zlgcan-linux":
|
||||
raise HardwareSafetyError(
|
||||
"the Linux passenger-seat path is fixed to CAN0"
|
||||
)
|
||||
if not self.profiles:
|
||||
raise HardwareSafetyError(
|
||||
"the combined IAR seat-controller firmware profile is not selected"
|
||||
@@ -357,15 +428,15 @@ class SeatHardwareSettings:
|
||||
target_key = (str(target.get("zone", "")), str(target.get("instance", "")))
|
||||
if target_key != SUPPORTED_PHYSICAL_TARGET:
|
||||
raise HardwareSafetyError(
|
||||
"the T2G bench accepts only passenger/front_right seat operations"
|
||||
"passenger CAN0 accepts only passenger/front_right seat operations"
|
||||
)
|
||||
try:
|
||||
profile = CAPABILITY_PROFILE[capability]
|
||||
except KeyError as exc:
|
||||
raise HardwareSafetyError(
|
||||
f"the T2G IAR firmware does not implement {capability}"
|
||||
f"the passenger-seat CAN0 whitelist does not implement {capability}"
|
||||
) from exc
|
||||
if profile not in self.profiles:
|
||||
if not self.native_passenger_can0 and profile not in self.profiles:
|
||||
raise HardwareSafetyError(
|
||||
f"{capability} requires the '{profile}' firmware profile"
|
||||
)
|
||||
@@ -373,7 +444,7 @@ class SeatHardwareSettings:
|
||||
physical_requests[capability] = parameters
|
||||
|
||||
def bus_factory(self) -> Callable[[], CanBus]:
|
||||
if self.backend == "zlgcan":
|
||||
if self.backend in {"zlgcan", "zlgcan-linux"}:
|
||||
try:
|
||||
numeric_channel = int(self.channel, 0)
|
||||
except ValueError as exc:
|
||||
@@ -447,7 +518,11 @@ class SeatHardwareSettings:
|
||||
"configured": self.configured,
|
||||
"approval_required": self.approval_required,
|
||||
"approval_configured": bool(self.approval_token),
|
||||
"platform": "t2g_seat_bench",
|
||||
"platform": (
|
||||
"n7_passenger_seat_can0"
|
||||
if self.native_passenger_can0
|
||||
else "t2g_seat_bench"
|
||||
),
|
||||
"backend": self.backend,
|
||||
"adapter_configured": self.adapter_configured,
|
||||
"channel": self.channel,
|
||||
@@ -456,15 +531,48 @@ class SeatHardwareSettings:
|
||||
"frame_format": "classic_can_11_bit",
|
||||
"firmware_profiles": sorted(self.profiles),
|
||||
"firmware_images": images,
|
||||
"firmware_status": "passive_protocol_verified_before_each_hardware_request",
|
||||
"firmware_protocol_signature": FIRMWARE_PROTOCOL_SIGNATURE,
|
||||
"firmware_source": "repository",
|
||||
"protocol_profile": (
|
||||
"passenger-seat-native-can0-v1"
|
||||
if self.native_passenger_can0
|
||||
else "legacy-t2g-combined"
|
||||
),
|
||||
"firmware_status": (
|
||||
"external_passenger_firmware_feedback_verified"
|
||||
if self.native_passenger_can0
|
||||
else "passive_protocol_verified_before_each_hardware_request"
|
||||
),
|
||||
"firmware_protocol_signature": (
|
||||
None
|
||||
if self.native_passenger_can0
|
||||
else FIRMWARE_PROTOCOL_SIGNATURE
|
||||
),
|
||||
"firmware_source": (
|
||||
"external_passenger_firmware"
|
||||
if self.native_passenger_can0
|
||||
else "repository"
|
||||
),
|
||||
"firmware_sources": (
|
||||
[
|
||||
"/home/orin/project511/passenger_seat_firmware/firmware",
|
||||
"/home/orin/project511/passenger_seat_firmware/Passenger_Seat_Controller_demo",
|
||||
]
|
||||
if self.native_passenger_can0
|
||||
else []
|
||||
),
|
||||
"auto_flash_enabled": self.auto_flash_enabled,
|
||||
"programmer_configured": self.programmer_configured,
|
||||
"programmer_backend": "iar_cspy_cmsisdap",
|
||||
"programmer_backend": (
|
||||
"not_applicable_external_firmware"
|
||||
if self.native_passenger_can0
|
||||
else "iar_cspy_cmsisdap"
|
||||
),
|
||||
"supported_targets": ["front_right"],
|
||||
"supported_seat_instances": ["passenger"],
|
||||
"physical_target": "t2g_primary_seat",
|
||||
"physical_target": (
|
||||
"n7_front_right_passenger_seat"
|
||||
if self.native_passenger_can0
|
||||
else "t2g_primary_seat"
|
||||
),
|
||||
"target_aliasing": "disabled_front_passenger_only",
|
||||
"auto_home_enabled": self.auto_home_enabled,
|
||||
"position_sync_enabled": self.position_sync_enabled,
|
||||
@@ -474,14 +582,19 @@ class SeatHardwareSettings:
|
||||
else "disabled"
|
||||
),
|
||||
"home_timeout_s": self.home_timeout_s,
|
||||
"limitations": [
|
||||
"limitations": ([
|
||||
"CAN0 is fixed to classic CAN 500 kbit/s with standard 11-bit identifiers",
|
||||
"control uses 0x300/0x29E/0x3CE and feedback uses 0x301-0x306/0x4FA",
|
||||
"CAN0 and CAN1 are opened by one Linux VCI device owner during linked execution",
|
||||
"all passenger-seat capabilities remain restricted to passenger/front_right",
|
||||
] if self.native_passenger_can0 else [
|
||||
"hardware execution requires the unified Seat_Controller_demo image on one development board",
|
||||
"all seat capabilities are restricted to passenger/front_right",
|
||||
"M0/M1/M2/M3 map to horizontal, seat-pan, backrest and legrest on one physical seat",
|
||||
"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",
|
||||
],
|
||||
]),
|
||||
}
|
||||
|
||||
|
||||
@@ -877,16 +990,28 @@ class VehicleMqttSettings:
|
||||
"frame_contract": "n7.can.frame.v1",
|
||||
"state_contract": "n7-can-signals/v1",
|
||||
"control_contract": "n7-headunit-can-command/v1",
|
||||
"can0_seat_protocol": "seat-ota-runtime",
|
||||
"can0_state_contract": "0x6A0-SEAT_INFO-plus-0x6A2-to-0x302",
|
||||
"can0_control_id": "0x6A0",
|
||||
"can0_control_opcodes": ["0x63", "0x64", "0x65", "0x66"],
|
||||
"can0_seat_protocol": "passenger-seat-native-can0-v1",
|
||||
"can0_state_contract": (
|
||||
"0x301-0x306-and-0x4FA; read-only legacy 0x6A2 may map to 0x302"
|
||||
),
|
||||
"can0_control_id": "0x300",
|
||||
"can0_control_ids": ["0x300", "0x29E", "0x3CE"],
|
||||
"can0_control_opcodes": [
|
||||
"0x01",
|
||||
"0x02",
|
||||
"0x03",
|
||||
"0x08",
|
||||
"0x09",
|
||||
"0x0B",
|
||||
"0x0E",
|
||||
"0x0F",
|
||||
],
|
||||
"can0_forbidden_workflows": [
|
||||
"flash",
|
||||
"bootloader_entry",
|
||||
"test",
|
||||
"calibration",
|
||||
"homing",
|
||||
"full_range_calibration",
|
||||
"clear_fault",
|
||||
],
|
||||
"approval_required": self.approval_required,
|
||||
"approval_configured": bool(self.approval_token),
|
||||
|
||||
@@ -4,9 +4,9 @@ The MQTT topics carry CAN frames, never decoded vehicle-state patches. Every
|
||||
incoming frame is therefore subjected to the same allow-list and byte decoder
|
||||
as a frame received from the wired ZLG adapter.
|
||||
|
||||
CAN0 is the classic-CAN passenger-seat protocol defined by
|
||||
``/home/orin/project511/seat-code-file/seat-ota`` and remains raw for the seat
|
||||
domain decoder. CAN1 is the rest-of-vehicle protocol defined by
|
||||
CAN0 is the classic-CAN passenger-seat protocol defined by the trusted sources
|
||||
under ``/home/orin/project511/passenger_seat_firmware`` and remains raw for the
|
||||
seat domain decoder. CAN1 is the rest-of-vehicle protocol defined by
|
||||
``/home/orin/project511/project-code/canfd/CAN矩阵表_filled.xlsx`` and is
|
||||
decoded by :mod:`cognitive_agent.application.native_can_feedback`.
|
||||
"""
|
||||
@@ -42,22 +42,32 @@ MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
|
||||
{*range(9), 12, 16, 20, 24, 32, 48, 64}
|
||||
)
|
||||
MQTT_SAFETY_FEEDBACK_IDS = frozenset({0x342, 0x4EB})
|
||||
MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset({0x2F5, 0x37B})
|
||||
# Frames accepted from the Android head unit are the seat-ota wire protocol.
|
||||
# ``0x302`` is an internal compatibility frame synthesized below so the
|
||||
# existing seat-domain state reader can consume the four firmware axes; it is
|
||||
# deliberately not accepted as authoritative MQTT input.
|
||||
MQTT_SEAT_WIRE_FEEDBACK_IDS = frozenset({0x6A0, 0x6A2})
|
||||
MQTT_SEAT_FEEDBACK_IDS = frozenset({0x302, 0x6A2})
|
||||
MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset(
|
||||
{0x2F5, 0x37B, 0x4BB}
|
||||
)
|
||||
# CAN0 accepts both state formats currently emitted by the passenger-seat
|
||||
# stack. The production firmware publishes its authoritative classic-CAN
|
||||
# status directly on 0x301..0x306/0x4FA. Older head-unit builds expose the
|
||||
# read-only seat-ota request/response exchange on 0x6A0/0x6A2; that exchange is
|
||||
# retained and translated to an internal 0x302 position frame below.
|
||||
MQTT_SEAT_NATIVE_FEEDBACK_IDS = frozenset(
|
||||
{0x301, 0x302, 0x303, 0x304, 0x305, 0x306, 0x4FA}
|
||||
)
|
||||
MQTT_SEAT_WIRE_FEEDBACK_IDS = MQTT_SEAT_NATIVE_FEEDBACK_IDS | frozenset(
|
||||
{0x6A0, 0x6A2}
|
||||
)
|
||||
MQTT_SEAT_FEEDBACK_IDS = MQTT_SEAT_NATIVE_FEEDBACK_IDS | frozenset({0x6A2})
|
||||
MQTT_ACCEPTED_FEEDBACK_IDS = {
|
||||
0: MQTT_SEAT_FEEDBACK_IDS,
|
||||
1: MQTT_VEHICLE_FEEDBACK_IDS,
|
||||
}
|
||||
MQTT_APPROVED_CONTROL_IDS = {
|
||||
0: frozenset({0x6A0}),
|
||||
0: frozenset({0x29E, 0x300, 0x3CE}),
|
||||
1: frozenset({0x1DE, 0x32B, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}),
|
||||
}
|
||||
MQTT_OTA_SEAT_RUNTIME_OPCODES = frozenset({0x63, 0x64, 0x65, 0x66})
|
||||
MQTT_NATIVE_SEAT_RUNTIME_OPCODES = frozenset(
|
||||
{0x01, 0x02, 0x03, 0x08, 0x09, 0x0B, 0x0E, 0x0F}
|
||||
)
|
||||
_ID_KEYS = ("arbitration_id", "can_id", "canId", "frame_id", "frameId", "id")
|
||||
_DATA_KEYS = ("data_hex", "dataHex", "data", "payload", "bytes")
|
||||
|
||||
@@ -186,16 +196,58 @@ def _parse_one_frame(value: Any, *, default_channel: int) -> CanFrame:
|
||||
)
|
||||
|
||||
|
||||
def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
|
||||
def _explicit_ota_seat_selector(value: Any, frame: CanFrame) -> int | None:
|
||||
"""Read trusted head-unit metadata that identifies a 0x6A2 response.
|
||||
|
||||
Recent head-unit builds publish only received 0x6A2 frames and attach the
|
||||
polled SEAT_INFO selector to each JSON signal. The selector is transport
|
||||
metadata rather than a CAN payload byte, so keep it only after validating
|
||||
both its bounded type and the complete read-only response signature.
|
||||
"""
|
||||
|
||||
if not isinstance(value, dict) or "selector" not in value:
|
||||
return None
|
||||
selector = value["selector"]
|
||||
if isinstance(selector, bool) or not isinstance(selector, int):
|
||||
raise ValueError( # noqa: TRY004
|
||||
"CAN0 seat selector must be an integer from 0 to 14"
|
||||
)
|
||||
if not 0 <= selector <= 14:
|
||||
raise ValueError("CAN0 seat selector must be an integer from 0 to 14")
|
||||
if not (
|
||||
frame.channel == 0
|
||||
and frame.arbitration_id == 0x6A2
|
||||
and len(frame.data) == 8
|
||||
and frame.data[0] == 0x7C
|
||||
and frame.data[2] == 0x60
|
||||
and frame.data[3] == 0x00
|
||||
):
|
||||
raise ValueError(
|
||||
"CAN0 seat selector is accepted only on a valid 0x6A2 SEAT_INFO response"
|
||||
)
|
||||
return selector
|
||||
|
||||
|
||||
def _ota_seat_status_frames(
|
||||
frames: list[CanFrame],
|
||||
explicit_selectors: list[int | None] | None = None,
|
||||
) -> list[CanFrame]:
|
||||
"""Translate read-only 0x6A0/0x6A2 seat telemetry into 0x302 states.
|
||||
|
||||
The seat-ota runtime returns a selector value in a generic response frame:
|
||||
0x6A0 is ``C7 sequence 60 selector ...`` and 0x6A2 is
|
||||
``7C sequence 60 status uint32-le``. Only the non-mutating SEAT_INFO
|
||||
opcode is paired here. The original 0x6A2 response remains available to
|
||||
opcode is paired here. Newer head-unit envelopes may instead carry a
|
||||
validated ``selector`` beside each received 0x6A2 frame. If both sources
|
||||
exist they must agree. The original 0x6A2 response remains available to
|
||||
command-response waiters; the generated 0x302 frame is internal only.
|
||||
"""
|
||||
|
||||
if explicit_selectors is None:
|
||||
explicit_selectors = [None] * len(frames)
|
||||
if len(explicit_selectors) != len(frames):
|
||||
raise ValueError("CAN0 seat selector metadata does not match the frame batch")
|
||||
|
||||
selectors_by_request: dict[tuple[int, int], int] = {}
|
||||
for frame in frames:
|
||||
if (
|
||||
@@ -209,7 +261,9 @@ def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
|
||||
selectors_by_request[(frame.data[1], frame.data[2])] = frame.data[3]
|
||||
|
||||
values: dict[int, tuple[int, CanFrame]] = {}
|
||||
for frame in frames:
|
||||
for frame, explicit_selector in zip(
|
||||
frames, explicit_selectors, strict=True
|
||||
):
|
||||
if (
|
||||
frame.channel != 0
|
||||
or frame.arbitration_id != 0x6A2
|
||||
@@ -219,7 +273,22 @@ def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
|
||||
or frame.data[3] != 0x00
|
||||
):
|
||||
continue
|
||||
selector = selectors_by_request.get((frame.data[1], frame.data[2]))
|
||||
request_selector = selectors_by_request.get(
|
||||
(frame.data[1], frame.data[2])
|
||||
)
|
||||
if (
|
||||
explicit_selector is not None
|
||||
and request_selector is not None
|
||||
and explicit_selector != request_selector
|
||||
):
|
||||
# Conflicting metadata must not be allowed to relabel one axis as
|
||||
# another. Keep the raw response but reject this conversion.
|
||||
continue
|
||||
selector = (
|
||||
explicit_selector
|
||||
if explicit_selector is not None
|
||||
else request_selector
|
||||
)
|
||||
if selector is None:
|
||||
continue
|
||||
values[selector] = (int.from_bytes(frame.data[4:8], "little"), frame)
|
||||
@@ -342,14 +411,16 @@ def parse_mqtt_can_payload(payload: bytes | str) -> list[CanFrame]:
|
||||
item_count = sum(len(items) for items, _ in batches)
|
||||
if not 1 <= item_count <= 256:
|
||||
raise ValueError("MQTT CAN frame batch must contain between 1 and 256 frames")
|
||||
parsed_frames = [
|
||||
_parse_one_frame(item, default_channel=default_channel)
|
||||
for items, default_channel in batches
|
||||
for item in items
|
||||
]
|
||||
frames = [
|
||||
frame
|
||||
for frame in parsed_frames
|
||||
parsed_entries: list[tuple[CanFrame, int | None]] = []
|
||||
for items, default_channel in batches:
|
||||
for item in items:
|
||||
frame = _parse_one_frame(item, default_channel=default_channel)
|
||||
parsed_entries.append(
|
||||
(frame, _explicit_ota_seat_selector(item, frame))
|
||||
)
|
||||
accepted_entries = [
|
||||
(frame, selector)
|
||||
for frame, selector in parsed_entries
|
||||
if frame.arbitration_id
|
||||
in (
|
||||
MQTT_SEAT_WIRE_FEEDBACK_IDS
|
||||
@@ -357,7 +428,12 @@ def parse_mqtt_can_payload(payload: bytes | str) -> list[CanFrame]:
|
||||
else MQTT_VEHICLE_FEEDBACK_IDS
|
||||
)
|
||||
]
|
||||
return [*frames, *_ota_seat_status_frames(frames)]
|
||||
frames = [frame for frame, _selector in accepted_entries]
|
||||
explicit_selectors = [selector for _frame, selector in accepted_entries]
|
||||
return [
|
||||
*frames,
|
||||
*_ota_seat_status_frames(frames, explicit_selectors),
|
||||
]
|
||||
|
||||
|
||||
def _validate_headunit_command_frame(frame: CanFrame) -> None:
|
||||
@@ -411,6 +487,10 @@ def mqtt_control_payload(
|
||||
"data": frame.data.hex(" ").upper(),
|
||||
"repeat": 1,
|
||||
"intervalMs": 0,
|
||||
# Vehicle state is consumed asynchronously from can/state by the
|
||||
# Cognitive Agent. Do not serialize Android's command worker on a
|
||||
# per-command response wait as well.
|
||||
"expectResponse": False,
|
||||
"responseTimeoutMs": bounded_timeout_ms,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
@@ -418,31 +498,93 @@ def mqtt_control_payload(
|
||||
)
|
||||
|
||||
|
||||
def _validate_ota_seat_runtime_control(data: bytes) -> None:
|
||||
"""Reject every CAN0 OTA opcode except normal bounded seat runtime control."""
|
||||
def _validate_native_seat_motion_control(data: bytes) -> None:
|
||||
"""Validate the firmware-native 0x300 semantic runtime subset.
|
||||
|
||||
if len(data) != 8 or data[0] != 0xC7:
|
||||
raise CanGatewayError("CAN0 seat-ota control requires C7 and DLC 8")
|
||||
opcode = data[2]
|
||||
selector = data[3]
|
||||
argument = int.from_bytes(data[4:8], "little")
|
||||
if opcode not in MQTT_OTA_SEAT_RUNTIME_OPCODES:
|
||||
The byte layout and limits match ``seat_demo_compat.inc`` and
|
||||
``Seat_Controller_demo/App/seat_move_application.c``. Maintenance-only
|
||||
calibration, generic query and clear-fault opcodes are intentionally not
|
||||
exposed through natural-language/MQTT control.
|
||||
"""
|
||||
|
||||
if len(data) != 8:
|
||||
raise CanGatewayError("CAN0 native 0x300 control requires DLC 8")
|
||||
opcode = data[0]
|
||||
control = data[1]
|
||||
motor = control & 0x03
|
||||
direction = (control >> 2) & 0x03
|
||||
version = (control >> 4) & 0x03
|
||||
sequence = int.from_bytes(data[2:4], "little")
|
||||
param_a = int.from_bytes(data[4:6], "little")
|
||||
param_b = int.from_bytes(data[6:8], "little")
|
||||
if version != 1 or control & 0xC0:
|
||||
raise CanGatewayError("CAN0 native 0x300 requires protocol version 1")
|
||||
if sequence == 0:
|
||||
raise CanGatewayError("CAN0 native 0x300 requires a runtime sequence")
|
||||
if opcode not in MQTT_NATIVE_SEAT_RUNTIME_OPCODES:
|
||||
raise CanGatewayError(
|
||||
"CAN0 seat-ota control allows only STOP, MOVE, HEARTBEAT, or DYNAMIC"
|
||||
"CAN0 native 0x300 opcode is outside the semantic control whitelist"
|
||||
)
|
||||
if opcode == 0x63 and (selector not in {*range(4), 0xFF} or argument != 0):
|
||||
raise CanGatewayError("CAN0 seat STOP selector or argument is invalid")
|
||||
if opcode == 0x64 and (selector not in range(4) or argument > 1_000):
|
||||
raise CanGatewayError("CAN0 seat MOVE requires axis 0..3 and target 0..1000")
|
||||
if opcode == 0x65 and (selector not in {*range(4), 0xFF} or argument != 0):
|
||||
raise CanGatewayError("CAN0 seat HEARTBEAT selector or argument is invalid")
|
||||
if opcode == 0x66 and (
|
||||
selector not in {1, 2}
|
||||
or (argument != 0 and not 5 <= argument <= 300)
|
||||
if opcode in {0x09, 0x0E, 0x0F} and motor != 0:
|
||||
raise CanGatewayError("CAN0 native global seat command requires motor 0")
|
||||
if opcode == 0x03:
|
||||
if direction not in {1, 2}:
|
||||
raise CanGatewayError("CAN0 native timed move requires direction 1 or 2")
|
||||
elif direction != 0:
|
||||
raise CanGatewayError("CAN0 native non-timed command requires direction 0")
|
||||
|
||||
bounded_timeout = param_b == 0 or 100 <= param_b <= 60_000
|
||||
if opcode == 0x01 and (param_a > 10_000 or not bounded_timeout):
|
||||
raise CanGatewayError("CAN0 native absolute move payload is invalid")
|
||||
if opcode == 0x02:
|
||||
relative = int.from_bytes(data[4:6], "little", signed=True)
|
||||
if not -10_000 <= relative <= 10_000 or not bounded_timeout:
|
||||
raise CanGatewayError("CAN0 native relative move payload is invalid")
|
||||
if opcode == 0x03 and not (
|
||||
100 <= param_a <= 60_000 and 300 <= param_b <= 1_000
|
||||
):
|
||||
raise CanGatewayError(
|
||||
"CAN0 seat DYNAMIC requires mode 1/2 and duration 5..300 seconds"
|
||||
)
|
||||
raise CanGatewayError("CAN0 native timed move payload is invalid")
|
||||
if opcode in {0x08, 0x09, 0x0F} and (param_a != 0 or param_b != 0):
|
||||
raise CanGatewayError("CAN0 native stop payload is invalid")
|
||||
if opcode == 0x0B and not (
|
||||
param_a == 0x4B52 and 10 <= param_b <= 600
|
||||
):
|
||||
raise CanGatewayError("CAN0 native known-range home payload is invalid")
|
||||
if opcode == 0x0E and (param_a not in {1, 2} or param_b != 0):
|
||||
raise CanGatewayError("CAN0 native dynamic-mode payload is invalid")
|
||||
|
||||
|
||||
def _validate_native_seat_thermal_control(frame: CanFrame) -> None:
|
||||
"""Validate the two runtime thermal frames implemented by the CAN0 firmware."""
|
||||
|
||||
if len(frame.data) != 8:
|
||||
raise CanGatewayError("CAN0 passenger-seat thermal control requires DLC 8")
|
||||
if frame.arbitration_id == 0x3CE:
|
||||
mode = (frame.data[0] >> 4) & 0x03
|
||||
if mode not in {1, 2} or frame.data[0] & 0xCF or any(frame.data[1:]):
|
||||
raise CanGatewayError("CAN0 0x3CE thermal mode payload is invalid")
|
||||
return
|
||||
if frame.arbitration_id != 0x29E:
|
||||
raise CanGatewayError("CAN0 native thermal frame ID is invalid")
|
||||
fan_on = frame.data[0] & 0x03
|
||||
fan_mode = (frame.data[0] >> 2) & 0x03
|
||||
heating_on = (frame.data[0] >> 4) & 0x03
|
||||
fan_level = frame.data[2] & 0x0F
|
||||
heating_level = (frame.data[2] >> 4) & 0x0F
|
||||
if (
|
||||
fan_on not in {1, 2}
|
||||
or fan_mode != 1
|
||||
or heating_on not in {1, 2}
|
||||
or fan_level not in range(4)
|
||||
or heating_level not in range(4)
|
||||
or (fan_on == 1 and fan_level == 0)
|
||||
or (fan_on == 2 and fan_level != 0)
|
||||
or (heating_on == 1 and heating_level == 0)
|
||||
or (heating_on == 2 and heating_level != 0)
|
||||
or frame.data[1] != 0
|
||||
or any(frame.data[3:])
|
||||
):
|
||||
raise CanGatewayError("CAN0 0x29E thermal control payload is invalid")
|
||||
|
||||
|
||||
class MqttCanTransport:
|
||||
@@ -629,7 +771,7 @@ class MqttCanTransport:
|
||||
discriminator = (
|
||||
frame.data[0]
|
||||
if frame.channel == 0
|
||||
and frame.arbitration_id == 0x302
|
||||
and frame.arbitration_id in {0x302, 0x303, 0x304}
|
||||
and frame.data
|
||||
else 0
|
||||
)
|
||||
@@ -747,7 +889,15 @@ class MqttCanTransport:
|
||||
initial_frames = tuple(
|
||||
frame
|
||||
for (seen_channel, _frame_id, _discriminator),
|
||||
(captured_monotonic, frame) in sorted(self._latest_bus_frames.items())
|
||||
(captured_monotonic, frame) in sorted(
|
||||
self._latest_bus_frames.items(),
|
||||
key=lambda item: (
|
||||
0 if item[0][1] == 0x306 else
|
||||
1 if item[0][1] == 0x4FA else
|
||||
2,
|
||||
item[0],
|
||||
),
|
||||
)
|
||||
if replay_latest
|
||||
and seen_channel == channel
|
||||
and now - captured_monotonic <= self.freshness_s
|
||||
@@ -768,8 +918,10 @@ class MqttCanTransport:
|
||||
if frame.channel == 0 and (frame.is_fd or frame.bitrate_switch):
|
||||
raise CanGatewayError("CAN0 passenger-seat control requires classic CAN")
|
||||
_validate_headunit_command_frame(frame)
|
||||
if frame.channel == 0:
|
||||
_validate_ota_seat_runtime_control(frame.data)
|
||||
if frame.channel == 0 and frame.arbitration_id == 0x300:
|
||||
_validate_native_seat_motion_control(frame.data)
|
||||
elif frame.channel == 0:
|
||||
_validate_native_seat_thermal_control(frame)
|
||||
with self._condition:
|
||||
client = self._client
|
||||
state = self._state
|
||||
@@ -788,14 +940,19 @@ class MqttCanTransport:
|
||||
)
|
||||
if int(getattr(result, "rc", 0)) != 0:
|
||||
raise CanGatewayError(f"MQTT CAN publish failed with code {result.rc}")
|
||||
if hasattr(result, "wait_for_publish"):
|
||||
published = result.wait_for_publish(timeout=2.0)
|
||||
if published is False:
|
||||
raise CanGatewayError("MQTT CAN publish confirmation timed out")
|
||||
# Paho already accepted the payload into its network-loop queue when
|
||||
# publish() returns rc == MQTT_ERR_SUCCESS. Waiting here serializes
|
||||
# every repeated CAN frame and previously added up to two seconds per
|
||||
# frame. Observe an already-available acknowledgement without ever
|
||||
# blocking the control pipeline.
|
||||
is_published = getattr(result, "is_published", None)
|
||||
broker_confirmed = bool(is_published()) if callable(is_published) else False
|
||||
return {
|
||||
"topic": self.settings.control_topic,
|
||||
"qos": self.settings.qos,
|
||||
"retain": False,
|
||||
"delivery_status": "published" if broker_confirmed else "queued",
|
||||
"message_id": getattr(result, "mid", None),
|
||||
"payload": json.loads(raw_payload),
|
||||
}
|
||||
|
||||
@@ -876,7 +1033,7 @@ class MqttCanBus:
|
||||
|
||||
@property
|
||||
def published_messages(self) -> tuple[dict[str, Any], ...]:
|
||||
"""Return this bus view's confirmed MQTT publications in send order."""
|
||||
"""Return this bus view's accepted MQTT publications in send order."""
|
||||
|
||||
return tuple(deepcopy(self._published_messages))
|
||||
|
||||
|
||||
+298
-31
@@ -44,6 +44,7 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
||||
HardwareBusyError,
|
||||
HardwareSafetyError,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.gateway import CanGatewayError # noqa: E402
|
||||
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
|
||||
HardwareAccessRequiredError,
|
||||
)
|
||||
@@ -362,7 +363,18 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
if path.startswith("/api/mode-profiles/"):
|
||||
self._handle_mode_profile_post(path)
|
||||
return
|
||||
if path != "/api/requests":
|
||||
supplement_match = re.fullmatch(
|
||||
r"/api/requests/(?P<request_id>req-[a-z0-9-]+)/supplement",
|
||||
path,
|
||||
)
|
||||
if (
|
||||
path not in {
|
||||
"/api/requests",
|
||||
"/api/requests/preview",
|
||||
"/api/can/commands",
|
||||
}
|
||||
and not supplement_match
|
||||
):
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
|
||||
@@ -376,7 +388,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
status="failed",
|
||||
utterance=utterance,
|
||||
source=source,
|
||||
error={"code": code, "message": str(error)},
|
||||
error={
|
||||
"code": code,
|
||||
"message": str(error),
|
||||
"outcome": "failed_or_blocked",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -395,42 +411,123 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
if self.auth.config.enabled:
|
||||
self.auth.api_requests.record(username)
|
||||
payload = self._read_json_body()
|
||||
utterance = str(payload.get("utterance", ""))
|
||||
source = str(payload.get("client_type", "head_unit"))
|
||||
self.vehicle_display_sync.update_task(
|
||||
status="running",
|
||||
utterance=utterance,
|
||||
source=source,
|
||||
direct_can_commands = (
|
||||
payload.get("commands") if path == "/api/can/commands" else None
|
||||
)
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
str(payload.get("provider", "auto")),
|
||||
str(payload["conversation_id"]) if payload.get("conversation_id") else None,
|
||||
str(payload.get("execution_mode", "dry_run")),
|
||||
utterance = (
|
||||
";".join(
|
||||
item if isinstance(item, str) else repr(item)
|
||||
for item in direct_can_commands
|
||||
)
|
||||
if isinstance(direct_can_commands, list)
|
||||
else str(payload.get("utterance", ""))
|
||||
)
|
||||
source = str(payload.get("client_type", "head_unit"))
|
||||
if utterance:
|
||||
self.vehicle_display_sync.update_task(
|
||||
status="running",
|
||||
utterance=utterance,
|
||||
source=source,
|
||||
)
|
||||
hardware_client_id = (
|
||||
str(payload["hardware_client_id"])
|
||||
if payload.get("hardware_client_id")
|
||||
else None
|
||||
)
|
||||
reasoning_effort = (
|
||||
str(payload["reasoning_effort"])
|
||||
if payload.get("reasoning_effort") is not None
|
||||
else None
|
||||
)
|
||||
common_arguments = {
|
||||
"utterance": utterance,
|
||||
"provider": str(payload.get("provider", "auto")),
|
||||
"conversation_id": (
|
||||
str(payload["conversation_id"])
|
||||
if payload.get("conversation_id")
|
||||
else None
|
||||
),
|
||||
"execution_mode": str(
|
||||
payload.get("execution_mode", "dry_run")
|
||||
),
|
||||
# Hardware authorization is a server deployment policy. The
|
||||
# browser no longer renders or transmits an approval secret.
|
||||
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"),
|
||||
vehicle_id=(
|
||||
# browser never receives or transmits this secret.
|
||||
"approval_token": os.getenv(
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
|
||||
),
|
||||
"vehicle_id": (
|
||||
str(payload["vehicle_id"])
|
||||
if payload.get("vehicle_id")
|
||||
else None
|
||||
),
|
||||
simulation_fault=str(payload.get("simulation_fault", "normal")),
|
||||
hardware_client_id=(
|
||||
str(payload["hardware_client_id"])
|
||||
if payload.get("hardware_client_id")
|
||||
else None
|
||||
"simulation_fault": str(
|
||||
payload.get("simulation_fault", "normal")
|
||||
),
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
reasoning_effort=(
|
||||
str(payload["reasoning_effort"])
|
||||
if payload.get("reasoning_effort") is not None
|
||||
else None
|
||||
),
|
||||
control_transport=str(
|
||||
"hardware_client_id": hardware_client_id,
|
||||
"hardware_access_bypass": self._is_unproxied_loopback_request(),
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"control_transport": str(
|
||||
payload.get("control_transport", "wired_can")
|
||||
),
|
||||
)
|
||||
}
|
||||
if (
|
||||
path != "/api/can/commands"
|
||||
and "application/x-ndjson" in self.headers.get("Accept", "")
|
||||
):
|
||||
self._stream_control_request(
|
||||
path,
|
||||
supplement_match,
|
||||
payload,
|
||||
common_arguments,
|
||||
source=source,
|
||||
)
|
||||
return
|
||||
if path == "/api/can/commands":
|
||||
result = self.service.process_can_commands(
|
||||
direct_can_commands,
|
||||
conversation_id=(
|
||||
str(payload["conversation_id"])
|
||||
if payload.get("conversation_id")
|
||||
else None
|
||||
),
|
||||
execution_mode=str(payload.get("execution_mode", "dry_run")),
|
||||
approval_token=os.getenv(
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
|
||||
),
|
||||
vehicle_id=(
|
||||
str(payload["vehicle_id"])
|
||||
if payload.get("vehicle_id")
|
||||
else None
|
||||
),
|
||||
simulation_fault=str(
|
||||
payload.get("simulation_fault", "normal")
|
||||
),
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
control_transport=str(
|
||||
payload.get("control_transport", "wired_can")
|
||||
),
|
||||
)
|
||||
elif path == "/api/requests/preview":
|
||||
result = self.service.preview_native_can(**common_arguments)
|
||||
elif supplement_match:
|
||||
result = self.service.supplement_native_can(
|
||||
supplement_match.group("request_id"),
|
||||
provider=(
|
||||
str(payload["provider"])
|
||||
if payload.get("provider")
|
||||
else None
|
||||
),
|
||||
approval_token=os.getenv(
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
|
||||
),
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
else:
|
||||
result = self.service.process(**common_arguments)
|
||||
utterance = str(result.get("utterance") or utterance)
|
||||
self.vehicle_display_sync.update_task(
|
||||
status=str(result.get("status", "succeeded")),
|
||||
utterance=utterance,
|
||||
@@ -444,6 +541,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
{
|
||||
"error": "hardware_access_required",
|
||||
"message": str(exc),
|
||||
"outcome": "failed_or_blocked",
|
||||
"access_state": exc.state,
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
@@ -454,6 +552,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
{
|
||||
"error": "hardware_busy",
|
||||
"message": str(exc),
|
||||
"outcome": "failed_or_blocked",
|
||||
"retry_after_seconds": exc.retry_after_seconds,
|
||||
},
|
||||
HTTPStatus.CONFLICT,
|
||||
@@ -462,12 +561,33 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
except HardwareSafetyError as exc:
|
||||
fail_display_sync("hardware_safety_gate", exc)
|
||||
self._json(
|
||||
{"error": "hardware_safety_gate", "message": str(exc)},
|
||||
{
|
||||
"error": "hardware_safety_gate",
|
||||
"message": str(exc),
|
||||
"outcome": "failed_or_blocked",
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
except CanGatewayError as exc:
|
||||
fail_display_sync("can_transport_failed", exc)
|
||||
self._json(
|
||||
{
|
||||
"error": "can_transport_failed",
|
||||
"message": str(exc),
|
||||
"outcome": "failed_or_blocked",
|
||||
},
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
fail_display_sync("invalid_request", exc)
|
||||
self._json({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
self._json(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"message": str(exc),
|
||||
"outcome": "failed_or_blocked",
|
||||
},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
except OpenAIIntentError as exc:
|
||||
fail_display_sync("openai_request_failed", exc)
|
||||
self.log_error("OpenAI intent request failed: %s", exc)
|
||||
@@ -475,6 +595,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
{
|
||||
"error": "openai_request_failed",
|
||||
"message": "GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
|
||||
"outcome": "failed_or_blocked",
|
||||
},
|
||||
HTTPStatus.BAD_GATEWAY,
|
||||
)
|
||||
@@ -485,10 +606,156 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
{
|
||||
"error": "internal_error",
|
||||
"message": "请求处理失败,请查看服务端日志。",
|
||||
"outcome": "failed_or_blocked",
|
||||
"detail": str(exc),
|
||||
},
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def _stream_control_request(
|
||||
self,
|
||||
path: str,
|
||||
supplement_match: re.Match[str] | None,
|
||||
payload: dict[str, object],
|
||||
common_arguments: dict[str, object],
|
||||
*,
|
||||
source: str,
|
||||
) -> None:
|
||||
"""Stream validated command plans before physical execution finishes."""
|
||||
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self._send_security_headers()
|
||||
self.end_headers()
|
||||
stream_open = True
|
||||
|
||||
def emit(event: dict[str, object]) -> None:
|
||||
nonlocal stream_open
|
||||
if not stream_open:
|
||||
return
|
||||
content = (json.dumps(event, ensure_ascii=False) + "\n").encode(
|
||||
"utf-8"
|
||||
)
|
||||
try:
|
||||
self.wfile.write(content)
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
# The submitted control request must continue even if the
|
||||
# browser closes while the physical execution is in progress.
|
||||
stream_open = False
|
||||
|
||||
def on_commands_validated(result: dict[str, object]) -> None:
|
||||
emit({"type": "validated_commands", "payload": result})
|
||||
|
||||
def emit_terminal_error(
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
detail: str | None = None,
|
||||
extra: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
"""Publish one terminal failure using can_read_trans outcome terms."""
|
||||
|
||||
error = {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"outcome": "failed_or_blocked",
|
||||
**({"detail": detail} if detail else {}),
|
||||
}
|
||||
self.vehicle_display_sync.update_task(
|
||||
status="failed",
|
||||
utterance=utterance,
|
||||
source=source,
|
||||
error=error,
|
||||
)
|
||||
emit({
|
||||
"type": "error",
|
||||
"error": code,
|
||||
"message": message,
|
||||
"outcome": "failed_or_blocked",
|
||||
**({"detail": detail} if detail else {}),
|
||||
**(extra or {}),
|
||||
})
|
||||
|
||||
utterance = str(common_arguments.get("utterance") or "")
|
||||
try:
|
||||
if path == "/api/requests/preview":
|
||||
result = self.service.preview_native_can(
|
||||
**common_arguments,
|
||||
on_commands_validated=on_commands_validated,
|
||||
)
|
||||
elif supplement_match:
|
||||
result = self.service.supplement_native_can(
|
||||
supplement_match.group("request_id"),
|
||||
provider=(
|
||||
str(payload["provider"])
|
||||
if payload.get("provider")
|
||||
else None
|
||||
),
|
||||
approval_token=os.getenv(
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
|
||||
),
|
||||
hardware_client_id=(
|
||||
str(payload["hardware_client_id"])
|
||||
if payload.get("hardware_client_id")
|
||||
else None
|
||||
),
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
reasoning_effort=(
|
||||
str(payload["reasoning_effort"])
|
||||
if payload.get("reasoning_effort") is not None
|
||||
else None
|
||||
),
|
||||
on_commands_validated=on_commands_validated,
|
||||
)
|
||||
else:
|
||||
result = self.service.process(
|
||||
**common_arguments,
|
||||
_on_commands_validated=on_commands_validated,
|
||||
)
|
||||
utterance = str(result.get("utterance") or utterance)
|
||||
self.vehicle_display_sync.update_task(
|
||||
status=str(result.get("status", "succeeded")),
|
||||
utterance=utterance,
|
||||
source=source,
|
||||
result=result,
|
||||
)
|
||||
emit({"type": "result", "payload": result})
|
||||
except HardwareAccessRequiredError as exc:
|
||||
emit_terminal_error(
|
||||
"hardware_access_required",
|
||||
str(exc),
|
||||
extra={"access_state": exc.state},
|
||||
)
|
||||
except HardwareBusyError as exc:
|
||||
emit_terminal_error(
|
||||
"hardware_busy",
|
||||
str(exc),
|
||||
extra={"retry_after_seconds": exc.retry_after_seconds},
|
||||
)
|
||||
except HardwareSafetyError as exc:
|
||||
emit_terminal_error("hardware_safety_gate", str(exc))
|
||||
except CanGatewayError as exc:
|
||||
emit_terminal_error("can_transport_failed", str(exc))
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
emit_terminal_error("invalid_request", str(exc))
|
||||
except OpenAIIntentError as exc:
|
||||
self.log_error("OpenAI intent request failed: %s", exc)
|
||||
emit_terminal_error(
|
||||
"openai_request_failed",
|
||||
"GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
|
||||
detail=str(exc),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - stream a terminal event
|
||||
self.log_error("streaming request failed: %s", exc)
|
||||
emit_terminal_error(
|
||||
"internal_error",
|
||||
"请求处理失败,请查看服务端日志。",
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
def do_DELETE(self) -> None: # noqa: N802 - stdlib handler API
|
||||
parsed_url = urlparse(self.path)
|
||||
path = unquote(parsed_url.path)
|
||||
|
||||
+212
-11
@@ -22,6 +22,7 @@ from cognitive_agent.application.self_extension import (
|
||||
)
|
||||
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError
|
||||
from cognitive_agent.infrastructure.can.access import HardwareAccessController
|
||||
from cognitive_agent.infrastructure.can.gateway import CanGatewayError
|
||||
from cognitive_agent.server import SeatDemoHandler
|
||||
|
||||
|
||||
@@ -40,6 +41,7 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
@@ -332,18 +334,18 @@ operations:
|
||||
def test_static_console_and_runtime_endpoint(self) -> None:
|
||||
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
||||
html = response.read().decode("utf-8")
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-101", timeout=3) as response:
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-107", timeout=3) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
|
||||
) as response:
|
||||
gesture_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/console-v2.js?v=sync-layout-41", timeout=3
|
||||
f"{self.base_url}/console-v2.js?v=task-outcome-47", timeout=3
|
||||
) as response:
|
||||
console_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/console-v2.css?v=sync-layout-40", timeout=3
|
||||
f"{self.base_url}/console-v2.css?v=sync-layout-41", timeout=3
|
||||
) as response:
|
||||
console_styles = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -378,12 +380,12 @@ operations:
|
||||
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
|
||||
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
|
||||
self.assertNotIn("透明车体 · 实体设备", html)
|
||||
self.assertIn('src="/app.js?v=dual-seat-101"', html)
|
||||
self.assertIn('src="/app.js?v=dual-seat-107"', html)
|
||||
self.assertIn('href="/styles.css?v=dual-seat-16"', html)
|
||||
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
|
||||
self.assertNotIn('class="vehicle-system-hud"', html)
|
||||
self.assertIn('src="/console-v2.js?v=sync-layout-41"', html)
|
||||
self.assertIn('href="/console-v2.css?v=sync-layout-40"', html)
|
||||
self.assertIn('src="/console-v2.js?v=task-outcome-47"', html)
|
||||
self.assertIn('href="/console-v2.css?v=sync-layout-41"', html)
|
||||
self.assertIn('<body class="console-v2">', html)
|
||||
self.assertIn('id="v2BootScreen"', html)
|
||||
self.assertIn('正在加载车机控制与实时状态', html)
|
||||
@@ -507,8 +509,10 @@ operations:
|
||||
self.assertNotIn("授权并真实执行", app_script)
|
||||
self.assertIn('const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"]', app_script)
|
||||
self.assertIn('divider.className = "mode-group-divider"', app_script)
|
||||
self.assertIn(
|
||||
'mode === "hardware" && !hardwareExecutionReady',
|
||||
self.assertIn("function isHardwareExecutionReady()", app_script)
|
||||
self.assertIn("function hardwareExecutionIssue(", app_script)
|
||||
self.assertNotIn(
|
||||
'if (mode === "hardware" && !hardwareExecutionReady) return false',
|
||||
app_script,
|
||||
)
|
||||
self.assertIn('data-control-transport="wired_can"', html)
|
||||
@@ -529,7 +533,15 @@ operations:
|
||||
'availableTransports.find((item) => item.state_configured)?.id',
|
||||
app_script,
|
||||
)
|
||||
self.assertIn("hardwareButton.disabled = !hardwareExecutionReady", app_script)
|
||||
self.assertIn("hardwareButton.disabled = false", app_script)
|
||||
self.assertIn(
|
||||
'hardwareButton.dataset.executionState = hardwareExecutionReady ? "available" : "blocked"',
|
||||
app_script,
|
||||
)
|
||||
self.assertNotIn(
|
||||
'id="hardwareMode" data-execution-mode="hardware" disabled',
|
||||
html,
|
||||
)
|
||||
self.assertIn('const seat = twin.selected === "driver" ? "driver" : "passenger"', app_script)
|
||||
self.assertNotIn('data-mode-command="seat driver vent low"', app_script)
|
||||
self.assertIn("CH0 副驾座椅与 CH1 主驾座椅独立收发、独立反馈", app_script)
|
||||
@@ -610,9 +622,15 @@ operations:
|
||||
self.assertIn("const stageHasLayout = () =>", app_script)
|
||||
self.assertIn("window.requestAnimationFrame(initSeatTwin)", app_script)
|
||||
self.assertIn('client_type: "web_console"', app_script)
|
||||
self.assertIn("function executionVerificationStatus(result = {})", app_script)
|
||||
self.assertIn('"submitted_unverified"', app_script)
|
||||
self.assertIn("指令已提交,等待状态确认", app_script)
|
||||
self.assertIn("指令已提交 · 等待状态确认", console_script)
|
||||
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
|
||||
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
||||
self.assertIn('"cognitive-agent:module-change"', app_script)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v202"', service_worker)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v210"', service_worker)
|
||||
self.assertIn('"/app.js?v=dual-seat-107"', service_worker)
|
||||
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
|
||||
self.assertIn(".then(() => self.skipWaiting())", service_worker)
|
||||
self.assertNotIn("const cached = await caches.match(request)", service_worker)
|
||||
@@ -852,7 +870,7 @@ operations:
|
||||
) as response:
|
||||
extension_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/app.js?v=dual-seat-101", timeout=3
|
||||
f"{self.base_url}/app.js?v=dual-seat-107", timeout=3
|
||||
) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
|
||||
@@ -1314,6 +1332,189 @@ operations:
|
||||
self.assertEqual(result["provider_used"], "local_rules_fallback")
|
||||
self.assertEqual(result["execution_report"]["spec"]["mode"], "dry_run")
|
||||
|
||||
def test_two_stage_http_api_returns_local_commands_before_cloud_supplement(
|
||||
self,
|
||||
) -> None:
|
||||
original_api_key = self.service.openai.api_key
|
||||
self.service.openai.api_key = "two-stage-http-test-key"
|
||||
preview_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests/preview",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(
|
||||
{
|
||||
"utterance": "空调温度设置为24度,播放音乐",
|
||||
"provider": "openai",
|
||||
"conversation_id": "conv-e2e-two-stage-http",
|
||||
"execution_mode": "dry_run",
|
||||
"reasoning_effort": "medium",
|
||||
"client_type": "web_console",
|
||||
}
|
||||
).encode("utf-8"),
|
||||
)
|
||||
model_output = {
|
||||
"detected_domains": ["ambient_light"],
|
||||
"requests": [
|
||||
{
|
||||
"domain": "ambient_light",
|
||||
"capability": "ambient_light.set_power",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"duration_ms": None,
|
||||
"parameters": [{"name": "state", "value": "on"}],
|
||||
"reason": "补充播放场景氛围灯。",
|
||||
}
|
||||
],
|
||||
"ambiguities": [],
|
||||
"decision_summary": ["仅补充本地未解析片段。"],
|
||||
"confidence": 0.9,
|
||||
}
|
||||
try:
|
||||
with patch.object(
|
||||
self.service.openai,
|
||||
"interpret",
|
||||
side_effect=AssertionError("preview must not invoke GPT"),
|
||||
):
|
||||
with self.opener.open(preview_request, timeout=3) as response:
|
||||
preview = json.loads(response.read().decode("utf-8"))
|
||||
self.assertEqual(response.status, 201)
|
||||
local_operation = next(
|
||||
operation
|
||||
for config in preview["function_configs"].values()
|
||||
for operation in config["spec"]["operations"]
|
||||
)
|
||||
self.assertEqual(local_operation["parse_source"], "local_rule")
|
||||
self.assertTrue(preview["supplement_required"])
|
||||
|
||||
supplement_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests/{preview['request_id']}/supplement",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(
|
||||
{
|
||||
"provider": "openai",
|
||||
"reasoning_effort": "medium",
|
||||
"client_type": "web_console",
|
||||
}
|
||||
).encode("utf-8"),
|
||||
)
|
||||
with patch.object(
|
||||
self.service.openai,
|
||||
"interpret",
|
||||
return_value=model_output,
|
||||
) as interpret:
|
||||
with self.opener.open(supplement_request, timeout=3) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
self.assertEqual(response.status, 201)
|
||||
self.assertEqual(interpret.call_args.args[0], "播放音乐")
|
||||
finally:
|
||||
self.service.openai.api_key = original_api_key
|
||||
|
||||
self.assertEqual(result["phase"], "two_stage_complete")
|
||||
sources = {
|
||||
operation["capability"]: operation["parse_source"]
|
||||
for config in result["function_configs"].values()
|
||||
for operation in config["spec"]["operations"]
|
||||
}
|
||||
self.assertEqual(sources["climate.set_temperature"], "local_rule")
|
||||
self.assertEqual(sources["ambient_light.set_power"], "cloud_supplement")
|
||||
|
||||
def test_preview_streams_validated_commands_before_terminal_result(self) -> None:
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests/preview",
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/x-ndjson",
|
||||
},
|
||||
data=json.dumps(
|
||||
{
|
||||
"utterance": "空调24度、风量2档",
|
||||
"provider": "local",
|
||||
"conversation_id": "conv-e2e-command-stream",
|
||||
"execution_mode": "dry_run",
|
||||
"client_type": "web_console",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
)
|
||||
|
||||
with self.opener.open(request, timeout=3) as response:
|
||||
events = [
|
||||
json.loads(line)
|
||||
for line in response.read().decode("utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
response.headers.get_content_type(),
|
||||
"application/x-ndjson",
|
||||
)
|
||||
self.assertEqual(
|
||||
[event["type"] for event in events],
|
||||
["validated_commands", "result"],
|
||||
)
|
||||
validated = events[0]["payload"]
|
||||
self.assertEqual(validated["status"], "processing")
|
||||
self.assertIsNone(validated["execution_report"])
|
||||
self.assertTrue(validated["command_plans"])
|
||||
self.assertEqual(events[1]["payload"]["status"], "succeeded")
|
||||
|
||||
def test_stream_failure_uses_cli_outcome_and_updates_display_sync_terminal_state(
|
||||
self,
|
||||
) -> None:
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests",
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/x-ndjson",
|
||||
},
|
||||
data=json.dumps(
|
||||
{
|
||||
"utterance": "打开副驾座椅通风",
|
||||
"provider": "local",
|
||||
"conversation_id": "conv-e2e-stream-can-failure",
|
||||
"execution_mode": "hardware",
|
||||
"client_type": "web_console",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
self.service,
|
||||
"process",
|
||||
side_effect=CanGatewayError("VCI_OpenDevice failed"),
|
||||
):
|
||||
with self.opener.open(request, timeout=3) as response:
|
||||
events = [
|
||||
json.loads(line)
|
||||
for line in response.read().decode("utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/api/vehicle-display-sync", timeout=3
|
||||
) as response:
|
||||
snapshot = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
self.assertEqual(events, [{
|
||||
"type": "error",
|
||||
"error": "can_transport_failed",
|
||||
"message": "VCI_OpenDevice failed",
|
||||
"outcome": "failed_or_blocked",
|
||||
}])
|
||||
self.assertEqual(snapshot["task"]["status"], "failed")
|
||||
self.assertEqual(snapshot["task"]["utterance"], "打开副驾座椅通风")
|
||||
self.assertEqual(
|
||||
snapshot["task"]["error"],
|
||||
{
|
||||
"code": "can_transport_failed",
|
||||
"message": "VCI_OpenDevice failed",
|
||||
"outcome": "failed_or_blocked",
|
||||
},
|
||||
)
|
||||
|
||||
def test_frontend_suite_covers_every_physical_seat_capability(self) -> None:
|
||||
suite_path = (
|
||||
REPOSITORY_ROOT
|
||||
|
||||
+26606
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -410,7 +410,7 @@
|
||||
],
|
||||
"rejection_cases": [
|
||||
{"id": "SAFE-DRIVER-THERMAL-LEVEL", "utterance": "主驾座椅通风4档", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-DRIVER-MASSAGE-GENERIC", "utterance": "打开主驾座椅按摩", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-DRIVER-THERMAL-MIDDLE", "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"},
|
||||
|
||||
@@ -96,6 +96,44 @@ class MultiDomainServiceTests(unittest.TestCase):
|
||||
self.addCleanup(self.temporary_directory.cleanup)
|
||||
self.service.artifact_root = Path(self.temporary_directory.name)
|
||||
|
||||
def test_driver_massage_mode_and_strength_continuations_compile_locally(
|
||||
self,
|
||||
) -> None:
|
||||
result = self.service.preview_native_can(
|
||||
"打开主驾座椅按摩,模式切换为脉冲模式,强度调到最大",
|
||||
provider="auto",
|
||||
conversation_id="conv-driver-massage-pulse-strong",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["unresolved_clauses"], [])
|
||||
self.assertFalse(result["supplement_required"])
|
||||
commands = [
|
||||
command
|
||||
for plan in result.get("command_plans", {}).values()
|
||||
for command in plan.get("spec", {}).get("commands", [])
|
||||
]
|
||||
self.assertEqual(
|
||||
[command["signals"]["debug_command"] for command in commands],
|
||||
[
|
||||
"seat driver massage mode pulse",
|
||||
"seat driver massage strength strong",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
[command["signals"]["data_hex"] for command in commands],
|
||||
[
|
||||
"12 00 00 03 00 00 00 00",
|
||||
"12 03 00 00 00 00 00 00",
|
||||
],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
operation["parse_source"] == "local_rule"
|
||||
for config in result.get("function_configs", {}).values()
|
||||
for operation in config.get("spec", {}).get("operations", [])
|
||||
)
|
||||
)
|
||||
|
||||
def test_four_domain_scene_is_generated_executed_and_persisted(self) -> None:
|
||||
model_output = {
|
||||
"detected_domains": ["seat", "climate", "roof", "ambient_light"],
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.mqtt_ota_seat_executor import MqttOtaSeatExecutor
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
CATALOG_PATH = (
|
||||
REPOSITORY_ROOT
|
||||
/ "tests"
|
||||
/ "fixtures"
|
||||
/ "natural_can_command_combination_suite.json"
|
||||
)
|
||||
HARDWARE_DISABLED_ENV = {
|
||||
"COGNITIVE_AGENT_CH1_HIL_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
}
|
||||
|
||||
|
||||
class NaturalCanCommandCombinationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.temporary_directory = tempfile.TemporaryDirectory()
|
||||
temporary_root = Path(cls.temporary_directory.name)
|
||||
memory_path = temporary_root / "driver-seat-memory.json"
|
||||
memory_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"positions": {
|
||||
str(slot): {
|
||||
"payload_15": "15 00 00 00 00 00 00 00",
|
||||
"payload_16": "16 00 00 00 00 00 00 00",
|
||||
}
|
||||
for slot in (1, 2, 3)
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cls.environment = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
**HARDWARE_DISABLED_ENV,
|
||||
"COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH": str(memory_path),
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
cls.environment.start()
|
||||
cls.service = SeatDemoService(REPOSITORY_ROOT)
|
||||
cls.service.artifact_root = temporary_root / "artifacts"
|
||||
cls.catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.environment.stop()
|
||||
cls.temporary_directory.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def _debug_commands(result: dict) -> list[str]:
|
||||
commands: list[str] = []
|
||||
for plan in result.get("command_plans", {}).values():
|
||||
for command in plan.get("spec", {}).get("commands", []):
|
||||
debug_command = command.get("signals", {}).get("debug_command")
|
||||
if isinstance(debug_command, str):
|
||||
commands.append(debug_command)
|
||||
return commands
|
||||
|
||||
@staticmethod
|
||||
def _can0_operations(result: dict, expected: list[dict]) -> Counter[str]:
|
||||
capabilities = {item["capability"] for item in expected}
|
||||
operations: list[dict] = []
|
||||
for config in result.get("function_configs", {}).values():
|
||||
for operation in config.get("spec", {}).get("operations", []):
|
||||
if operation.get("capability") not in capabilities:
|
||||
continue
|
||||
target = operation.get("target", {})
|
||||
operations.append(
|
||||
{
|
||||
"target": target.get("instance", target.get("zone")),
|
||||
"capability": operation["capability"],
|
||||
"parameters": operation.get("parameters", {}),
|
||||
}
|
||||
)
|
||||
return Counter(
|
||||
json.dumps(item, ensure_ascii=False, sort_keys=True)
|
||||
for item in operations
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _expected_can0_operations(expected: list[dict]) -> Counter[str]:
|
||||
return Counter(
|
||||
json.dumps(item, ensure_ascii=False, sort_keys=True)
|
||||
for item in expected
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _single_passenger_motion_operation(result: dict) -> dict:
|
||||
operations = [
|
||||
operation
|
||||
for config in result.get("function_configs", {}).values()
|
||||
for operation in config.get("spec", {}).get("operations", [])
|
||||
if operation.get("capability")
|
||||
in {
|
||||
"seat.set_longitudinal_position",
|
||||
"seat.set_cushion_tilt",
|
||||
"seat.set_backrest_recline",
|
||||
"seat.set_legrest_height",
|
||||
}
|
||||
]
|
||||
if len(operations) != 1:
|
||||
raise AssertionError(f"expected one passenger motion operation: {operations}")
|
||||
return operations[0]
|
||||
|
||||
def test_catalog_metadata_and_all_expanded_cases(self) -> None:
|
||||
cases = self.catalog["cases"]
|
||||
self.assertEqual(
|
||||
self.catalog["schema"],
|
||||
"natural-can-command-combination-suite/v1",
|
||||
)
|
||||
self.assertEqual(self.catalog["summary"]["total_cases"], len(cases))
|
||||
self.assertGreaterEqual(len(cases), 1000)
|
||||
self.assertEqual(len({item["id"] for item in cases}), len(cases))
|
||||
|
||||
for index, case in enumerate(cases):
|
||||
with self.subTest(case=case["id"], utterance=case["utterance"]):
|
||||
result = self.service.process(
|
||||
case["utterance"],
|
||||
provider="local",
|
||||
conversation_id=f"conv-natural-combination-{index:04d}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], case["expected_status"])
|
||||
self.assertEqual(
|
||||
self._debug_commands(result),
|
||||
case["expected_commands"],
|
||||
)
|
||||
expected_can0 = case["expected_can0_operations"]
|
||||
if expected_can0:
|
||||
self.assertEqual(
|
||||
self._can0_operations(result, expected_can0),
|
||||
self._expected_can0_operations(expected_can0),
|
||||
)
|
||||
|
||||
def test_can0_native_motion_and_dynamic_modes_combine_with_can1(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
"把副驾座椅水平位置调到35%,空调温度设置为24度,打开氛围灯",
|
||||
["ac temp driver 24", "light ambient on"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.set_longitudinal_position",
|
||||
"parameters": {"position_pct": 35},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"副驾坐盆向上调整10%,关闭后排车窗,空调风量2档",
|
||||
["window rear close", "ac fan 2"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.set_cushion_tilt",
|
||||
"parameters": {"position_pct": 40},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"开启木马模式并打开氛围灯",
|
||||
["light ambient on"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.start_dynamic_mode",
|
||||
"parameters": {"mode_id": 1},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"开启动态摇篮模式,空调24度,打开遮阳帘",
|
||||
["ac temp driver 24", "shade open"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.start_dynamic_mode",
|
||||
"parameters": {"mode_id": 2},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"副驾座椅前后调到20%、坐盆调到30%、靠背调到40%、腿托调到50%,空调风量3档,关闭全部车窗",
|
||||
["ac fan 3", "window all close"],
|
||||
[
|
||||
{
|
||||
"target": "passenger",
|
||||
"capability": capability,
|
||||
"parameters": {"position_pct": position},
|
||||
}
|
||||
for capability, position in (
|
||||
("seat.set_longitudinal_position", 20),
|
||||
("seat.set_cushion_tilt", 30),
|
||||
("seat.set_backrest_recline", 40),
|
||||
("seat.set_legrest_height", 50),
|
||||
)
|
||||
],
|
||||
),
|
||||
(
|
||||
"副驾靠背向后移动15%,主驾温度设为25度,遮阳帘打开",
|
||||
["ac temp driver 25", "shade open"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.set_backrest_recline",
|
||||
"parameters": {"position_pct": 35},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"副驾靠背往后转动10%,空调温度设置为24度",
|
||||
["ac temp driver 24"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.set_backrest_recline",
|
||||
"parameters": {"position_pct": 30},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"副驾腿托设置为40%,关闭副驾车窗",
|
||||
["window front-right close"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.set_legrest_height",
|
||||
"parameters": {"position_pct": 40},
|
||||
}],
|
||||
),
|
||||
)
|
||||
|
||||
for index, (utterance, can1_commands, can0_operations) in enumerate(cases):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can0-can1-targeted-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(result), can1_commands)
|
||||
self.assertEqual(
|
||||
self._can0_operations(result, can0_operations),
|
||||
self._expected_can0_operations(can0_operations),
|
||||
)
|
||||
|
||||
def test_passenger_relative_and_absolute_directional_commands_stay_distinct(self) -> None:
|
||||
cases = (
|
||||
("副驾座椅往上移动10%", "basin", "decrease", "up", 10),
|
||||
("副驾座椅往上移动20%", "basin", "decrease", "up", 20),
|
||||
("副驾往下移动10%", "basin", "increase", "down", 10),
|
||||
("副驾往下移动20%", "basin", "increase", "down", 20),
|
||||
("副驾座椅往前移动10%", "horizontal", "decrease", "forward", 10),
|
||||
("副驾座椅往前移动20%", "horizontal", "decrease", "forward", 20),
|
||||
("副驾往后移动10%", "horizontal", "increase", "backward", 10),
|
||||
("副驾往后移动20%", "horizontal", "increase", "backward", 20),
|
||||
("副驾座椅靠背往前移动10%", "backrest", "decrease", "forward", 10),
|
||||
("副驾座椅靠背往前移动30%", "backrest", "decrease", "forward", 30),
|
||||
("副驾靠背往后移动10%", "backrest", "increase", "backward", 10),
|
||||
("副驾靠背往后移动30%", "backrest", "increase", "backward", 30),
|
||||
("副驾推托往上移动20%", "legrest", "increase", "up", 20),
|
||||
("副驾推托往上移动40%", "legrest", "increase", "up", 40),
|
||||
("副驾推托往下移动20%", "legrest", "decrease", "down", 20),
|
||||
("副驾推托往下移动40%", "legrest", "decrease", "down", 40),
|
||||
)
|
||||
|
||||
for index, (utterance, axis, direction, command_direction, amount) in enumerate(
|
||||
cases
|
||||
):
|
||||
with self.subTest(kind="relative", utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-passenger-relative-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
operation = self._single_passenger_motion_operation(result)
|
||||
motion = operation["motion"]
|
||||
self.assertEqual(motion["mode"], "relative")
|
||||
self.assertEqual(motion["direction"], direction)
|
||||
self.assertEqual(
|
||||
motion.get(
|
||||
"requested_delta_position_pct",
|
||||
motion["delta_position_pct"],
|
||||
),
|
||||
amount,
|
||||
)
|
||||
self.assertTrue(
|
||||
MqttOtaSeatExecutor._operation_debug_command(operation).startswith(
|
||||
f"seat passenger {axis} relative {command_direction} "
|
||||
)
|
||||
)
|
||||
|
||||
absolute_utterance = utterance.replace("移动", "移动到")
|
||||
with self.subTest(kind="absolute", utterance=absolute_utterance):
|
||||
result = self.service.process(
|
||||
absolute_utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-passenger-absolute-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
operation = self._single_passenger_motion_operation(result)
|
||||
self.assertNotIn("motion", operation)
|
||||
self.assertEqual(operation["parameters"]["position_pct"], amount)
|
||||
self.assertEqual(
|
||||
MqttOtaSeatExecutor._operation_debug_command(operation),
|
||||
f"seat passenger {axis} position {amount}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,575 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.native_can_intent import (
|
||||
parse_exact_native_can_command,
|
||||
)
|
||||
from cognitive_agent.application.native_can_runtime import (
|
||||
NativeCanCompileError,
|
||||
NativeCanRuntime,
|
||||
)
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
HARDWARE_DISABLED_ENV = {
|
||||
"COGNITIVE_AGENT_CH1_HIL_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
}
|
||||
|
||||
|
||||
class Can1SemanticWhitelistTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary_directory.cleanup)
|
||||
temporary_root = Path(self.temporary_directory.name)
|
||||
memory_path = temporary_root / "driver-seat-memory.json"
|
||||
memory_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"positions": {
|
||||
str(slot): {
|
||||
"payload_15": "15 00 00 00 00 00 00 00",
|
||||
"payload_16": "16 00 00 00 00 00 00 00",
|
||||
}
|
||||
for slot in (1, 2, 3)
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.environment = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
**HARDWARE_DISABLED_ENV,
|
||||
"COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH": str(memory_path),
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self.environment.start()
|
||||
self.addCleanup(self.environment.stop)
|
||||
self.runtime = NativeCanRuntime(REPOSITORY_ROOT)
|
||||
self.service = SeatDemoService(REPOSITORY_ROOT)
|
||||
self.service.artifact_root = temporary_root / "artifacts"
|
||||
|
||||
@staticmethod
|
||||
def _valid_exact_commands() -> list[str]:
|
||||
commands = [f"door rear {state}" for state in ("lock", "unlock")]
|
||||
commands.extend(
|
||||
f"window {target} {motion}"
|
||||
for target in (
|
||||
"all",
|
||||
"front",
|
||||
"rear",
|
||||
"front-left",
|
||||
"front-right",
|
||||
"rear-left",
|
||||
"rear-right",
|
||||
)
|
||||
for motion in ("open", "close", "stop")
|
||||
)
|
||||
commands.extend(
|
||||
f"window {target} position {position}"
|
||||
for target in (
|
||||
"front-left",
|
||||
"front-right",
|
||||
"rear-left",
|
||||
"rear-right",
|
||||
)
|
||||
for position in (0, 100)
|
||||
)
|
||||
commands.extend(("ac on", "ac off"))
|
||||
commands.extend(f"ac fan {level}" for level in range(1, 8))
|
||||
commands.extend(
|
||||
f"ac mode {mode}"
|
||||
for mode in (
|
||||
"face",
|
||||
"face-feet",
|
||||
"feet",
|
||||
"feet-defrost",
|
||||
"defrost",
|
||||
"face-feet-defrost",
|
||||
"face-defrost",
|
||||
)
|
||||
)
|
||||
commands.extend(
|
||||
f"ac temp {target} {temperature}"
|
||||
for target in ("driver", "passenger", "rear")
|
||||
for temperature in (18, 32)
|
||||
)
|
||||
commands.extend(("ac control manual", "ac control auto"))
|
||||
commands.extend(("ac cycle internal", "ac cycle external"))
|
||||
commands.extend(
|
||||
f"outlet {target} {state}"
|
||||
for target in (
|
||||
"driver-left",
|
||||
"driver-right",
|
||||
"passenger-left",
|
||||
"passenger-right",
|
||||
)
|
||||
for state in ("open", "close")
|
||||
)
|
||||
commands.extend(
|
||||
f"outlet {target} percent {horizontal} {vertical}"
|
||||
for target in (
|
||||
"driver-left",
|
||||
"driver-right",
|
||||
"passenger-left",
|
||||
"passenger-right",
|
||||
)
|
||||
for horizontal, vertical in ((0, 0), (100, 100))
|
||||
)
|
||||
commands.extend(
|
||||
(
|
||||
"outlet driver-left position -25 -40",
|
||||
"outlet driver-right position 30 30",
|
||||
"outlet passenger-left position -10 -40",
|
||||
"outlet passenger-right position 5 30",
|
||||
)
|
||||
)
|
||||
commands.extend(
|
||||
f"seat driver {feature} {level}"
|
||||
for feature in ("vent", "heat")
|
||||
for level in ("off", "low", "high")
|
||||
)
|
||||
commands.append("seat driver massage off")
|
||||
commands.extend(
|
||||
f"seat driver massage strength {strength}"
|
||||
for strength in ("weak", "medium", "strong")
|
||||
)
|
||||
commands.extend(
|
||||
f"seat driver massage mode {mode}"
|
||||
for mode in ("wave", "pulse", "stretch", "slack", "relax")
|
||||
)
|
||||
commands.extend(
|
||||
(
|
||||
"seat driver horizontal forward 100",
|
||||
"seat driver horizontal backward 2000",
|
||||
"seat driver horizontal stop",
|
||||
"seat driver height up 100",
|
||||
"seat driver height down 2000",
|
||||
"seat driver height stop",
|
||||
"seat driver backrest forward 100",
|
||||
"seat driver backrest backward 2000",
|
||||
"seat driver backrest stop",
|
||||
"seat driver basin up 100",
|
||||
"seat driver basin down 2000",
|
||||
"seat driver basin stop",
|
||||
)
|
||||
)
|
||||
commands.extend(f"seat driver position {slot}" for slot in (1, 2, 3))
|
||||
commands.extend(
|
||||
f"seat driver memory recall {slot}" for slot in (1, 2, 3)
|
||||
)
|
||||
commands.extend(f"light indoor {state}" for state in ("on", "off"))
|
||||
commands.extend(
|
||||
f"light reading all {state}" for state in ("on", "off")
|
||||
)
|
||||
commands.extend(f"light ambient {state}" for state in ("on", "off"))
|
||||
commands.extend(f"light ambient color {value}" for value in (1, 127))
|
||||
commands.extend(
|
||||
f"light ambient brightness {value}" for value in (1, 5)
|
||||
)
|
||||
commands.extend(
|
||||
f"shade {motion}" for motion in ("open", "close", "half", "stop")
|
||||
)
|
||||
commands.extend(
|
||||
f"shade position {position}" for position in (0, 1, 50, 99, 100)
|
||||
)
|
||||
return commands
|
||||
|
||||
def _compile_exact(self, command: str) -> dict:
|
||||
actions = parse_exact_native_can_command(command)
|
||||
self.assertEqual(len(actions), 1, command)
|
||||
action = actions[0]
|
||||
config = self.runtime.build_function_config(
|
||||
action["domain"],
|
||||
actions,
|
||||
"req-can-one-whitelist",
|
||||
"local_rules",
|
||||
)
|
||||
return self.runtime.compile(config)
|
||||
|
||||
@staticmethod
|
||||
def _debug_commands(result: dict) -> list[str]:
|
||||
return [
|
||||
command["signals"]["debug_command"]
|
||||
for plan in result.get("command_plans", {}).values()
|
||||
for command in plan["spec"]["commands"]
|
||||
]
|
||||
|
||||
def test_every_documented_exact_command_parses_and_compiles(self) -> None:
|
||||
commands = self._valid_exact_commands()
|
||||
self.assertEqual(len(commands), 129)
|
||||
|
||||
for command in commands:
|
||||
with self.subTest(command=command):
|
||||
plan = self._compile_exact(command)
|
||||
self.assertEqual(
|
||||
[
|
||||
item["signals"]["debug_command"]
|
||||
for item in plan["spec"]["commands"]
|
||||
],
|
||||
[command],
|
||||
)
|
||||
|
||||
def test_out_of_whitelist_exact_values_are_rejected(self) -> None:
|
||||
commands = (
|
||||
"door rear open",
|
||||
"window vehicle open",
|
||||
"window all position 50",
|
||||
"window front-left position -1",
|
||||
"window front-left position 101",
|
||||
"ac fan 0",
|
||||
"ac fan 8",
|
||||
"ac mode auto",
|
||||
"ac temp driver 17",
|
||||
"ac temp driver 33",
|
||||
"ac temp driver 24.5",
|
||||
"ac control off",
|
||||
"outlet driver-left toggle",
|
||||
"outlet driver-left percent -1 50",
|
||||
"outlet driver-left percent 101 50",
|
||||
"seat driver vent medium",
|
||||
"seat driver horizontal forward 99",
|
||||
"seat driver horizontal forward 2001",
|
||||
"seat driver horizontal stop 500",
|
||||
"seat driver position 0",
|
||||
"seat driver position 4",
|
||||
"light ambient color 0",
|
||||
"light ambient color 128",
|
||||
"light ambient brightness 0",
|
||||
"light ambient brightness 6",
|
||||
"shade position -1",
|
||||
"shade position 101",
|
||||
)
|
||||
for command in commands:
|
||||
with self.subTest(command=command):
|
||||
self.assertEqual(parse_exact_native_can_command(command), [])
|
||||
|
||||
def test_calibrated_outlet_values_must_fit_the_selected_outlet(self) -> None:
|
||||
action = parse_exact_native_can_command(
|
||||
"outlet driver-left position -26 -40"
|
||||
)[0]
|
||||
config = self.runtime.build_function_config(
|
||||
action["domain"],
|
||||
[action],
|
||||
"req-can-one-calibration",
|
||||
"local_rules",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
NativeCanCompileError, "horizontal_value exceeds the calibrated range"
|
||||
):
|
||||
self.runtime.compile(config)
|
||||
|
||||
def test_individual_outlet_switches_use_observed_byte_exact_triplets(self) -> None:
|
||||
cases = {
|
||||
"outlet driver-left open": "02 FF FF FF FF 01 FF FF",
|
||||
"outlet driver-left close": "02 FF FF FF FF 02 FF FF",
|
||||
"outlet driver-right open": "02 FF FF FF FF 04 FF FF",
|
||||
"outlet driver-right close": "02 FF FF FF FF 08 FF FF",
|
||||
"outlet passenger-left open": "03 FF FF FF FF 01 00 00",
|
||||
"outlet passenger-left close": "03 FF FF FF FF 02 00 00",
|
||||
"outlet passenger-right open": "03 FF FF FF FF 04 00 00",
|
||||
"outlet passenger-right close": "03 FF FF FF FF 08 00 00",
|
||||
}
|
||||
for command, expected_payload in cases.items():
|
||||
with self.subTest(command=command):
|
||||
plan = self._compile_exact(command)
|
||||
compiled = plan["spec"]["commands"][0]
|
||||
self.assertEqual(compiled["signals"]["frame_id"], "0x4EF")
|
||||
self.assertEqual(compiled["signals"]["data_hex"], expected_payload)
|
||||
self.assertTrue(compiled["signals"]["is_fd"])
|
||||
self.assertTrue(compiled["signals"]["bitrate_switch"])
|
||||
self.assertEqual(
|
||||
compiled["repeat"], {"count": 3, "period_ms": 100}
|
||||
)
|
||||
self.assertEqual(
|
||||
compiled["signals"]["feedback_ids"], "0x4BB,0x37B"
|
||||
)
|
||||
|
||||
def test_representative_chinese_commands_cover_every_whitelist_family(self) -> None:
|
||||
cases = (
|
||||
("后排车门上锁", ["door rear lock"]),
|
||||
("后排门锁解锁", ["door rear unlock"]),
|
||||
("打开全部车窗", ["window all open"]),
|
||||
("关闭前排车窗", ["window front close"]),
|
||||
("停止后排车窗", ["window rear stop"]),
|
||||
("把副驾车窗开到35%", ["window front-right position 35"]),
|
||||
("打开空调", ["ac on"]),
|
||||
("关闭空调", ["ac off"]),
|
||||
("风量调到7档", ["ac fan 7"]),
|
||||
("空调出风模式设为吹脸", ["ac mode face"]),
|
||||
("空调出风模式设为吹面和吹脚", ["ac mode face-feet"]),
|
||||
("空调出风模式设为吹脚", ["ac mode feet"]),
|
||||
("空调出风模式设为吹脚除霜", ["ac mode feet-defrost"]),
|
||||
("空调出风模式设为除霜", ["ac mode defrost"]),
|
||||
("空调出风模式设为吹脸和吹脚除霜", ["ac mode face-feet-defrost"]),
|
||||
("空调出风模式设为吹脸除霜", ["ac mode face-defrost"]),
|
||||
("主驾空调温度设为18度", ["ac temp driver 18"]),
|
||||
("副驾空调温度设为32度", ["ac temp passenger 32"]),
|
||||
("后排空调温度设为24度", ["ac temp rear 24"]),
|
||||
("空调设置为自动控制", ["ac control auto"]),
|
||||
("空调设置为手动控制", ["ac control manual"]),
|
||||
("打开主驾左风口", ["outlet driver-left open"]),
|
||||
("关闭主驾右风口", ["outlet driver-right close"]),
|
||||
("打开副驾左吹面出风口", ["outlet passenger-left open"]),
|
||||
("关闭副驾右吹面出风口", ["outlet passenger-right close"]),
|
||||
("主驾左风口水平25%上下75%", ["outlet driver-left percent 25 75"]),
|
||||
("副驾右风口左右25%垂直75%", ["outlet passenger-right percent 25 75"]),
|
||||
("主驾左风口标定坐标左右-25上下-40", ["outlet driver-left position -25 -40"]),
|
||||
("打开主驾座椅通风", ["seat driver vent low"]),
|
||||
("关闭主驾座椅加热", ["seat driver heat off"]),
|
||||
("主驾座椅加热高档", ["seat driver heat high"]),
|
||||
("关闭主驾座椅按摩", ["seat driver massage off"]),
|
||||
("主驾座椅按摩强度调为中等", ["seat driver massage strength medium"]),
|
||||
("主驾座椅按摩模式设为放松", ["seat driver massage mode relax"]),
|
||||
("主驾座椅向前移动500毫秒", ["seat driver horizontal forward 500"]),
|
||||
("主驾座椅高度向下调500毫秒", ["seat driver height down 500"]),
|
||||
("主驾座椅靠背后仰800毫秒", ["seat driver backrest backward 800"]),
|
||||
("把主驾座椅坐盆抬高300毫秒", ["seat driver basin up 300"]),
|
||||
("调用主驾座椅记忆位置2", ["seat driver position 2"]),
|
||||
("打开室内灯", ["light indoor on"]),
|
||||
("关闭全部阅读灯", ["light reading all off"]),
|
||||
("打开氛围灯", ["light ambient on"]),
|
||||
("关闭氛围灯", ["light ambient off"]),
|
||||
("把氛围灯颜色编号调到70", ["light ambient color 70"]),
|
||||
("氛围灯亮度调整为3档", ["light ambient brightness 3"]),
|
||||
("打开遮阳帘", ["shade open"]),
|
||||
("关闭遮阳帘", ["shade close"]),
|
||||
("遮阳帘半开", ["shade half"]),
|
||||
("停止遮阳帘", ["shade stop"]),
|
||||
("遮阳帘开到20%", ["shade position 20"]),
|
||||
("遮阳帘打开30%", ["shade position 30"]),
|
||||
)
|
||||
for index, (utterance, expected) in enumerate(cases):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-one-natural-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(result), expected)
|
||||
|
||||
rear_context = self.service.conversations.snapshot(
|
||||
"conv-can-one-natural-18"
|
||||
)
|
||||
self.assertEqual(
|
||||
rear_context["domain_devices"]["climate"]["zones"]["rear_row"][
|
||||
"temperature_c"
|
||||
],
|
||||
24,
|
||||
)
|
||||
|
||||
def test_disabling_one_outlet_feature_never_enables_that_mode(self) -> None:
|
||||
for index, utterance in enumerate(
|
||||
("关闭吹面出风口", "把吹面出风口关闭")
|
||||
):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-one-close-outlet-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "needs_confirmation")
|
||||
self.assertEqual(self._debug_commands(result), [])
|
||||
ambiguities = result["intent_ir"]["spec"]["ambiguities"]
|
||||
self.assertEqual(
|
||||
ambiguities[0]["reason"],
|
||||
"outlet_feature_disable_requires_absolute_mode",
|
||||
)
|
||||
|
||||
preview = self.service.preview_native_can(
|
||||
"关闭吹面出风口",
|
||||
provider="openai",
|
||||
conversation_id="conv-can-one-close-outlet-preview",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertFalse(preview["supplement_required"])
|
||||
self.assertEqual(preview["unresolved_clauses"], [])
|
||||
self.assertEqual(self._debug_commands(preview), [])
|
||||
|
||||
explicit_replacement = self.service.process(
|
||||
"关闭吹面出风口,切换到吹脚模式",
|
||||
provider="local",
|
||||
conversation_id="conv-can-one-replace-outlet",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(explicit_replacement["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(explicit_replacement), ["ac mode feet"])
|
||||
|
||||
def test_driver_seat_natural_language_levels_and_motion_aliases(self) -> None:
|
||||
cases = (
|
||||
("打开主驾座椅按摩", ["seat driver massage mode relax"]),
|
||||
(
|
||||
"主驾座椅按摩强度设置为最大",
|
||||
["seat driver massage strength strong"],
|
||||
),
|
||||
(
|
||||
"主驾座椅按摩强度最小",
|
||||
["seat driver massage strength weak"],
|
||||
),
|
||||
(
|
||||
"主驾座椅按摩强度调中",
|
||||
["seat driver massage strength medium"],
|
||||
),
|
||||
("主驾座椅朝前挪一秒", ["seat driver horizontal forward 1000"]),
|
||||
("主驾座椅后退两秒", ["seat driver horizontal backward 2000"]),
|
||||
("主驾座椅椅背放倒", ["seat driver backrest backward 500"]),
|
||||
("主驾座椅座垫放低", ["seat driver basin down 500"]),
|
||||
("主驾座椅往上移动", ["seat driver height up 500"]),
|
||||
("主驾座椅通风最低", ["seat driver vent low"]),
|
||||
("主驾座椅通风最小", ["seat driver vent low"]),
|
||||
("主驾座椅通风最高", ["seat driver vent high"]),
|
||||
("主驾座椅通风最大", ["seat driver vent high"]),
|
||||
("空调风量设置为最低", ["ac fan 1"]),
|
||||
("空调风量设置为中等", ["ac fan 4"]),
|
||||
("空调风量设置为最大", ["ac fan 7"]),
|
||||
("氛围灯亮度最低", ["light ambient brightness 1"]),
|
||||
("氛围灯亮度中等", ["light ambient brightness 3"]),
|
||||
("氛围灯亮度最高", ["light ambient brightness 5"]),
|
||||
(
|
||||
"打开主驾座椅通风并关闭按摩",
|
||||
["seat driver vent low", "seat driver massage off"],
|
||||
),
|
||||
(
|
||||
"关闭主驾座椅通风并打开按摩",
|
||||
["seat driver vent off", "seat driver massage mode relax"],
|
||||
),
|
||||
)
|
||||
for index, (utterance, expected) in enumerate(cases):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-one-seat-alias-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(result), expected)
|
||||
|
||||
stop_all = self.service.process(
|
||||
"停止主驾座椅移动",
|
||||
provider="local",
|
||||
conversation_id="conv-can-one-seat-stop-all",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(stop_all["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
self._debug_commands(stop_all),
|
||||
[
|
||||
"seat driver horizontal stop",
|
||||
"seat driver height stop",
|
||||
"seat driver backrest stop",
|
||||
"seat driver basin stop",
|
||||
],
|
||||
)
|
||||
|
||||
def test_unsupported_driver_seat_middle_thermal_level_is_explicit(self) -> None:
|
||||
for index, feature in enumerate(("通风", "加热")):
|
||||
utterance = f"主驾座椅{feature}调到中档"
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-one-seat-middle-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "needs_confirmation")
|
||||
self.assertEqual(self._debug_commands(result), [])
|
||||
self.assertIn(
|
||||
"driver_seat_thermal_middle_level_unsupported",
|
||||
{
|
||||
item.get("reason")
|
||||
for item in result["intent_ir"]["spec"]["ambiguities"]
|
||||
},
|
||||
)
|
||||
|
||||
preview = self.service.preview_native_can(
|
||||
"主驾座椅通风设置为中等",
|
||||
provider="openai",
|
||||
conversation_id="conv-can-one-seat-middle-preview",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertFalse(preview["supplement_required"])
|
||||
self.assertEqual(preview["unresolved_clauses"], [])
|
||||
self.assertEqual(self._debug_commands(preview), [])
|
||||
|
||||
def test_vehicle_position_aliases_stay_scoped_to_the_device_domain(self) -> None:
|
||||
cases = (
|
||||
("打开左前车窗", ["window front-left open"]),
|
||||
("打开右前车窗", ["window front-right open"]),
|
||||
("打开左后车窗", ["window rear-left open"]),
|
||||
("打开右后车窗", ["window rear-right open"]),
|
||||
("打开主驾车窗", ["window front-left open"]),
|
||||
("打开副驾车窗", ["window front-right open"]),
|
||||
("打开左前座椅通风", ["seat driver vent low"]),
|
||||
("打开主驾座椅通风", ["seat driver vent low"]),
|
||||
("主驾空调温度设置为24度", ["ac temp driver 24"]),
|
||||
("副驾空调温度设置为24度", ["ac temp passenger 24"]),
|
||||
)
|
||||
for index, (utterance, expected) in enumerate(cases):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-position-alias-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(result), expected)
|
||||
|
||||
for index, utterance in enumerate(
|
||||
("打开右前座椅通风", "打开副驾座椅通风")
|
||||
):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-passenger-alias-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
operations = [
|
||||
operation
|
||||
for config in result.get("function_configs", {}).values()
|
||||
for operation in config.get("spec", {}).get("operations", [])
|
||||
]
|
||||
self.assertNotIn(
|
||||
"seat.set_driver_ventilation_native",
|
||||
{item["capability"] for item in operations},
|
||||
)
|
||||
self.assertEqual(operations[0]["capability"], "seat.set_ventilation")
|
||||
self.assertEqual(operations[0]["target"]["zone"], "front_right")
|
||||
|
||||
def test_fractional_values_are_not_silently_truncated(self) -> None:
|
||||
utterances = (
|
||||
"空调温度设为24.5度",
|
||||
"风量设为2.5档",
|
||||
"氛围灯颜色编号调到70.5",
|
||||
"氛围灯亮度调到3.5档",
|
||||
)
|
||||
for index, utterance in enumerate(utterances):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-one-fraction-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(self._debug_commands(result), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -154,6 +154,44 @@ class Ch1DefaultTargetTests(unittest.TestCase):
|
||||
"front_right",
|
||||
)
|
||||
|
||||
def test_driver_and_passenger_window_aliases_map_to_the_correct_side(self) -> None:
|
||||
cases = (
|
||||
("打开主驾车窗", "front_left", "window.set_motion", {"motion": "open"}),
|
||||
("打开主驾驶的车窗", "front_left", "window.set_motion", {"motion": "open"}),
|
||||
("打开副驾车窗", "front_right", "window.set_motion", {"motion": "open"}),
|
||||
("打开副驾驶的车窗", "front_right", "window.set_motion", {"motion": "open"}),
|
||||
("把副驾车窗开到35%", "front_right", "window.set_position", {"position_pct": 35}),
|
||||
)
|
||||
for index, (utterance, target, capability, parameters) in enumerate(cases):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-window-occupant-alias-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(set(result["function_configs"]), {"native_window"})
|
||||
operation = result["function_configs"]["native_window"]["spec"][
|
||||
"operations"
|
||||
][0]
|
||||
self.assertEqual(operation["target"]["zone"], target)
|
||||
self.assertEqual(operation["capability"], capability)
|
||||
self.assertEqual(operation["parameters"], parameters)
|
||||
|
||||
def test_window_target_does_not_leak_from_a_later_seat_subject(self) -> None:
|
||||
parsed = merge_native_can_intent({}, "打开副驾车窗和主驾座椅通风")
|
||||
windows = [
|
||||
action
|
||||
for action in parsed["native_can_actions"]
|
||||
if action["capability"] == "window.set_motion"
|
||||
]
|
||||
|
||||
self.assertEqual(len(windows), 1)
|
||||
self.assertEqual(windows[0]["target"], "front_right")
|
||||
self.assertEqual(windows[0]["parameters"], {"motion": "open"})
|
||||
|
||||
def test_group_or_non_driver_seat_scope_is_not_treated_as_unscoped(self) -> None:
|
||||
for index, utterance in enumerate(
|
||||
("打开前排座椅加热", "打开后排座椅加热", "打开所有座椅加热")
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.direct_can_control import build_direct_can_intent
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
def test_driver_device_batch_keeps_each_exact_control_independent() -> None:
|
||||
commands = [
|
||||
"seat driver vent low",
|
||||
"seat driver heat off",
|
||||
"seat driver massage mode stretch",
|
||||
"seat driver massage strength strong",
|
||||
]
|
||||
|
||||
normalized, parsed = build_direct_can_intent(commands)
|
||||
|
||||
assert normalized == commands
|
||||
assert [item["capability"] for item in parsed["native_can_actions"]] == [
|
||||
"seat.set_driver_ventilation_native",
|
||||
"seat.set_driver_heating_native",
|
||||
"seat.set_driver_massage_native",
|
||||
"seat.set_driver_massage_native",
|
||||
]
|
||||
assert [item["parameters"] for item in parsed["native_can_actions"]] == [
|
||||
{"state": "on", "level": 2},
|
||||
{"state": "off"},
|
||||
{"action": "mode", "mode": "stretch"},
|
||||
{"action": "strength", "strength": 3},
|
||||
]
|
||||
assert {
|
||||
item["parse_source"] for item in parsed["native_can_actions"]
|
||||
} == {"direct_control"}
|
||||
|
||||
|
||||
def test_passenger_device_batch_builds_can0_position_action() -> None:
|
||||
normalized, parsed = build_direct_can_intent(
|
||||
["seat passenger backrest position 36"]
|
||||
)
|
||||
|
||||
assert normalized == ["seat passenger backrest position 36"]
|
||||
assert parsed["native_can_actions"] == []
|
||||
assert parsed["actions"] == [
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": "backrest_recline",
|
||||
"position_pct": 36,
|
||||
"parse_source": "direct_control",
|
||||
"source_span": "seat passenger backrest position 36",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_passenger_can0_canonical_absolute_relative_thermal_and_dynamic() -> None:
|
||||
context = {
|
||||
"devices": {
|
||||
"passenger": {
|
||||
"position": {
|
||||
"longitudinal": {"position_pct": 40},
|
||||
"cushion_tilt": {"position_pct": 50},
|
||||
"backrest_recline": {"position_pct": 60},
|
||||
"legrest_height": {"position_pct": 70},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
commands = [
|
||||
"seat passenger horizontal position 35",
|
||||
"seat passenger basin relative up 10",
|
||||
"seat passenger vent medium",
|
||||
"seat passenger dynamic rocking-horse",
|
||||
"seat passenger dynamic stop",
|
||||
]
|
||||
|
||||
normalized, parsed = build_direct_can_intent(commands, context)
|
||||
|
||||
assert normalized == commands
|
||||
assert parsed["detected_domains"] == ["seat"]
|
||||
assert parsed["actions"][0]["axis"] == "longitudinal"
|
||||
assert parsed["actions"][0]["position_pct"] == 35
|
||||
assert parsed["actions"][1]["axis"] == "cushion_tilt"
|
||||
assert parsed["actions"][1]["position_pct"] == 40
|
||||
assert parsed["actions"][1]["motion_intent"] == {
|
||||
"mode": "relative",
|
||||
"direction": "decrease",
|
||||
"start_position_pct": 50,
|
||||
"delta_position_pct": 10,
|
||||
}
|
||||
assert parsed["actions"][2]["function"] == "ventilation"
|
||||
assert parsed["actions"][2]["requested_level"] == 2
|
||||
assert parsed["actions"][3]["action_type"] == "dynamic_mode"
|
||||
assert parsed["actions"][3]["mode_id"] == 1
|
||||
assert parsed["actions"][4]["action_type"] == "dynamic_mode_stop"
|
||||
|
||||
|
||||
def test_passenger_can0_relative_command_requires_current_position() -> None:
|
||||
with pytest.raises(ValueError, match="requires a valid current"):
|
||||
build_direct_can_intent(
|
||||
["seat passenger horizontal relative forward 10"],
|
||||
{"devices": {"passenger": {"position": {}}}},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"commands",
|
||||
(
|
||||
[],
|
||||
["seat driver vent medium"],
|
||||
["seat passenger height position 101"],
|
||||
["headlight low-beam on"],
|
||||
),
|
||||
)
|
||||
def test_direct_batch_rejects_non_whitelisted_commands(commands: list[str]) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
build_direct_can_intent(commands)
|
||||
|
||||
|
||||
def test_service_direct_batch_bypasses_natural_language_interpreter() -> None:
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = Path(directory) / "artifacts"
|
||||
with patch.object(
|
||||
service,
|
||||
"_interpret",
|
||||
side_effect=AssertionError("natural-language parsing must not run"),
|
||||
):
|
||||
result = service.process_can_commands(
|
||||
[
|
||||
"ac on",
|
||||
"ac temp driver 24",
|
||||
"ac fan 2",
|
||||
"ac cycle internal",
|
||||
],
|
||||
execution_mode="dry_run",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["provider_used"] == "direct_can_control"
|
||||
assert result["control_transport"] == "wireless_mqtt"
|
||||
assert result["can_commands"] == [
|
||||
"ac on",
|
||||
"ac temp driver 24",
|
||||
"ac fan 2",
|
||||
"ac cycle internal",
|
||||
]
|
||||
operations = [
|
||||
operation
|
||||
for configuration in result["function_configs"].values()
|
||||
for operation in configuration["spec"]["operations"]
|
||||
]
|
||||
assert [item["capability"] for item in operations] == [
|
||||
"climate.set_power",
|
||||
"climate.set_temperature",
|
||||
"climate.set_fan_level",
|
||||
"climate.set_cycle_mode",
|
||||
]
|
||||
assert {item["parse_source"] for item in operations} == {"direct_control"}
|
||||
|
||||
|
||||
def test_service_direct_batch_combines_can0_and_can1_semantics() -> None:
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = Path(directory) / "artifacts"
|
||||
result = service.process_can_commands(
|
||||
[
|
||||
"seat passenger basin relative up 10",
|
||||
"seat passenger vent medium",
|
||||
"seat passenger dynamic rocking-horse",
|
||||
"ac fan 2",
|
||||
"light ambient on",
|
||||
],
|
||||
conversation_id="conv-direct-can0-can1",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["can_commands"] == [
|
||||
"seat passenger basin relative up 10",
|
||||
"seat passenger vent medium",
|
||||
"seat passenger dynamic rocking-horse",
|
||||
"ac fan 2",
|
||||
"light ambient on",
|
||||
]
|
||||
operations = [
|
||||
operation
|
||||
for configuration in result["function_configs"].values()
|
||||
for operation in configuration["spec"]["operations"]
|
||||
]
|
||||
by_capability = {item["capability"]: item for item in operations}
|
||||
assert by_capability["seat.set_cushion_tilt"]["parameters"] == {
|
||||
"position_pct": 40
|
||||
}
|
||||
assert by_capability["seat.set_cushion_tilt"]["motion"] == {
|
||||
"mode": "relative",
|
||||
"direction": "decrease",
|
||||
"start_position_pct": 50,
|
||||
"delta_position_pct": 10,
|
||||
}
|
||||
assert by_capability["seat.start_dynamic_mode"]["parameters"] == {
|
||||
"mode_id": 1
|
||||
}
|
||||
assert by_capability["seat.set_ventilation"]["parameters"] == {
|
||||
"state": "on",
|
||||
"level": 2,
|
||||
}
|
||||
assert by_capability["climate.set_fan_level"]["parameters"] == {"level": 2}
|
||||
assert by_capability["ambient_light.set_power"]["parameters"] == {
|
||||
"state": "on"
|
||||
}
|
||||
assert {item["parse_source"] for item in operations} == {"direct_control"}
|
||||
@@ -190,6 +190,10 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "mode", "mode": "wave"},
|
||||
),
|
||||
"打开主驾座椅按摩": (
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "mode", "mode": "relax"},
|
||||
),
|
||||
"主驾座椅按摩强度调高": (
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "strength", "strength": 3},
|
||||
@@ -211,9 +215,17 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
{"axis": "height", "direction": "up", "duration_ms": 500},
|
||||
),
|
||||
"主驾座椅调整到记忆位置3": (
|
||||
"seat.recall_driver_memory_native",
|
||||
"seat.set_driver_position_native",
|
||||
{"slot": 3},
|
||||
),
|
||||
"调用主驾座椅记忆位置2": (
|
||||
"seat.set_driver_position_native",
|
||||
{"slot": 2},
|
||||
),
|
||||
"恢复主驾座椅到二号记忆位置": (
|
||||
"seat.set_driver_position_native",
|
||||
{"slot": 2},
|
||||
),
|
||||
}
|
||||
for utterance, expected in cases.items():
|
||||
with self.subTest(utterance=utterance):
|
||||
@@ -223,6 +235,61 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
self.assertEqual((action["capability"], action["parameters"]), expected)
|
||||
self.assertEqual(action["target"], "driver")
|
||||
|
||||
def test_multiclause_massage_preserves_five_modes_and_three_strengths(
|
||||
self,
|
||||
) -> None:
|
||||
modes = {
|
||||
"波浪": "wave",
|
||||
"脉冲": "pulse",
|
||||
"伸展": "stretch",
|
||||
"松弛": "slack",
|
||||
"放松": "relax",
|
||||
}
|
||||
strengths = {
|
||||
"最小": 1,
|
||||
"中等": 2,
|
||||
"最大": 3,
|
||||
}
|
||||
for mode_text, mode in modes.items():
|
||||
for strength_text, strength in strengths.items():
|
||||
utterance = (
|
||||
f"打开主驾座椅按摩,模式切换为{mode_text}模式,"
|
||||
f"强度调到{strength_text}"
|
||||
)
|
||||
with self.subTest(utterance=utterance):
|
||||
parsed = merge_native_can_intent({}, utterance)
|
||||
actions = parsed.get("native_can_actions", [])
|
||||
self.assertEqual(
|
||||
[item["parameters"] for item in actions],
|
||||
[
|
||||
{"action": "mode", "mode": mode},
|
||||
{"action": "strength", "strength": strength},
|
||||
],
|
||||
)
|
||||
|
||||
parsed = merge_native_can_intent(
|
||||
{},
|
||||
"打开主驾座椅按摩,然后把模式切换为脉冲模式,"
|
||||
"同时把强度调到最大",
|
||||
)
|
||||
config = self.runtime.build_function_config(
|
||||
"seat",
|
||||
parsed["native_can_actions"],
|
||||
"req-driver-massage-multiclause",
|
||||
"local_rules",
|
||||
)
|
||||
plan = self.runtime.compile(config)
|
||||
self.assertEqual(
|
||||
[
|
||||
command["signals"]["debug_command"]
|
||||
for command in plan["spec"]["commands"]
|
||||
],
|
||||
[
|
||||
"seat driver massage mode pulse",
|
||||
"seat driver massage strength strong",
|
||||
],
|
||||
)
|
||||
|
||||
def test_parser_rejects_passenger_right_front_and_unsafe_guesses(self) -> None:
|
||||
utterances = (
|
||||
"seat passenger vent low",
|
||||
@@ -231,7 +298,6 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
"打开右前座椅加热",
|
||||
"打开主驾和副驾座椅通风",
|
||||
"seat driver vent low, seat passenger vent low",
|
||||
"打开主驾座椅按摩",
|
||||
"主驾座椅向前移动50毫秒",
|
||||
"seat driver height forward 500",
|
||||
)
|
||||
@@ -442,6 +508,9 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
)
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertFalse(telemetry["physical_effect_confirmed"])
|
||||
self.assertEqual(
|
||||
telemetry["verification_status"], "submitted_unverified"
|
||||
)
|
||||
self.assertEqual(len(telemetry["sent_frames"]), 20)
|
||||
|
||||
uninitialized_bus = _FakeBus(
|
||||
@@ -585,7 +654,11 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
wrong_report = NativeCanExecutor(
|
||||
lambda: wrong_bus, feedback_timeout_ms=1
|
||||
).execute(self.runtime, plan, config)
|
||||
self.assertEqual(wrong_report["spec"]["status"], "failed")
|
||||
self.assertEqual(wrong_report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
wrong_report["spec"]["verification_status"],
|
||||
"submitted_unverified",
|
||||
)
|
||||
self.assertFalse(
|
||||
wrong_report["spec"]["results"][0]["telemetry"]["feedback_match"]
|
||||
)
|
||||
@@ -641,8 +714,13 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
self.runtime, plan, config
|
||||
)
|
||||
# 0x494 proves only initialization and cannot confirm that the timed
|
||||
# motion physically occurred, so strict hardware feedback fails closed.
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
# motion physically occurred. Submission succeeds at the orchestration
|
||||
# layer while physical verification remains explicitly incomplete.
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
report["spec"]["verification_status"],
|
||||
"submitted_unverified",
|
||||
)
|
||||
self.assertEqual(len(bus.sent), 8)
|
||||
self.assertTrue(all(frame.is_fd and frame.bitrate_switch for frame in bus.sent))
|
||||
self.assertEqual(
|
||||
@@ -651,6 +729,10 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
)
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertIsNone(telemetry["feedback_match"])
|
||||
self.assertEqual(
|
||||
telemetry["verification_status"], "submitted_unverified"
|
||||
)
|
||||
self.assertFalse(telemetry["physical_effect_confirmed"])
|
||||
self.assertTrue(telemetry["feedback_required"])
|
||||
self.assertTrue(telemetry["feedback_observed"])
|
||||
self.assertEqual(
|
||||
|
||||
@@ -306,7 +306,7 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
||||
self.assertIn("const driverOnlyCh1 = isDriverOnlyCh1Runtime()", submit_source)
|
||||
self.assertIn("&& !driverOnlyCh1", submit_source)
|
||||
self.assertIn(
|
||||
'state.executionMode === "hardware" && !driverOnlyCh1',
|
||||
'&& state.controlTransport !== "wireless_mqtt"',
|
||||
submit_source,
|
||||
)
|
||||
startup_source = self.app_source[
|
||||
|
||||
@@ -21,15 +21,18 @@ from cognitive_agent.infrastructure.can.mqtt_transport import (
|
||||
parse_mqtt_can_payload,
|
||||
)
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class _PublishResult:
|
||||
rc = 0
|
||||
mid = 17
|
||||
|
||||
def is_published(self) -> bool:
|
||||
return True
|
||||
|
||||
def wait_for_publish(self, timeout: float) -> bool:
|
||||
return timeout > 0
|
||||
raise AssertionError("control publish must never block for acknowledgement")
|
||||
|
||||
|
||||
class _FakeMqttClient:
|
||||
@@ -199,7 +202,7 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
self.assertEqual(frame.arbitration_id, 0x42E)
|
||||
self.assertTrue(frame.is_fd)
|
||||
|
||||
def test_ignores_legacy_can0_state_outside_seat_ota_wire_protocol(self) -> None:
|
||||
def test_accepts_native_passenger_seat_firmware_status(self) -> None:
|
||||
frames = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -216,7 +219,9 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertEqual(frames, [])
|
||||
self.assertEqual(len(frames), 1)
|
||||
self.assertEqual(frames[0].channel, 0)
|
||||
self.assertEqual(frames[0].arbitration_id, 0x302)
|
||||
|
||||
def test_translates_seat_ota_info_responses_into_four_position_states(self) -> None:
|
||||
values = {
|
||||
@@ -258,6 +263,89 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
)
|
||||
self.assertTrue(all(frame.data[2] & (1 << 3) for frame in positions))
|
||||
|
||||
def test_translates_headunit_6a2_selector_metadata_without_6a0_requests(
|
||||
self,
|
||||
) -> None:
|
||||
values = {
|
||||
1: bytes.fromhex("E0 01 E0 01"), # 480 / 480
|
||||
2: bytes.fromhex("00 00 75 01"), # 0 / 373
|
||||
3: bytes.fromhex("00 00 95 03"), # 0 / 917
|
||||
4: bytes.fromhex("BF 01 87 02"), # 447 / 647
|
||||
5: bytes.fromhex("11 00 00 00"),
|
||||
6: bytes.fromhex("11 00 00 00"),
|
||||
7: bytes.fromhex("11 00 02 00"),
|
||||
8: bytes.fromhex("11 00 00 00"),
|
||||
}
|
||||
frames = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"signals": [
|
||||
{
|
||||
"channel": "can0",
|
||||
"channelIndex": 0,
|
||||
"canId": "0x6A2",
|
||||
"frameType": "classic",
|
||||
"brs": False,
|
||||
"dlc": 8,
|
||||
"selector": selector,
|
||||
"bytes": [0x7C, 0x30 + selector, 0x60, 0, *value],
|
||||
"direction": "rx",
|
||||
}
|
||||
for selector, value in values.items()
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
positions = [frame for frame in frames if frame.arbitration_id == 0x302]
|
||||
self.assertEqual([frame.data[0] for frame in positions], [0, 1, 2, 3])
|
||||
self.assertEqual(
|
||||
[int.from_bytes(frame.data[5:7], "little") for frame in positions],
|
||||
[10_000, 0, 0, round(447 * 10_000 / 647)],
|
||||
)
|
||||
self.assertTrue(all(frame.data[2] & (1 << 3) for frame in positions))
|
||||
|
||||
def test_rejects_invalid_or_conflicting_headunit_seat_selector_metadata(
|
||||
self,
|
||||
) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "integer from 0 to 14"):
|
||||
parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x6A2",
|
||||
"selector": "1",
|
||||
"bytes": [0x7C, 1, 0x60, 0, 0xE0, 1, 0xE0, 1],
|
||||
}
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "valid 0x6A2"):
|
||||
parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x302",
|
||||
"selector": 1,
|
||||
"bytes": [0] * 8,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
frames = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
"signals": [
|
||||
{"channel": "can0", "canId": "0x6A0", "bytes":
|
||||
[0xC7, 0x21, 0x60, 1, 0, 0, 0, 0]},
|
||||
{"channel": "can0", "canId": "0x6A2", "selector": 2,
|
||||
"bytes": [0x7C, 0x21, 0x60, 0, 0xE0, 1, 0xE0, 1]},
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertFalse(any(frame.arbitration_id == 0x302 for frame in frames))
|
||||
|
||||
def test_rejects_can0_frames_outside_seat_firmware_format(self) -> None:
|
||||
for label, frame in (
|
||||
(
|
||||
@@ -313,7 +401,7 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
[(frame.channel, frame.arbitration_id) for frame in frames],
|
||||
[(1, 0x342)],
|
||||
[(0, 0x302), (1, 0x342)],
|
||||
)
|
||||
|
||||
def test_rejects_decoded_state_invalid_channels_and_channel_conflicts(self) -> None:
|
||||
@@ -431,6 +519,133 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
self.assertLess(time.perf_counter() - started, 0.05)
|
||||
self.assertEqual(snapshot.observed_ids, frozenset({0x388}))
|
||||
|
||||
def test_native_can0_status_maps_to_passenger_frontend_state(self) -> None:
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"signals": [
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x302",
|
||||
"frameType": "classic",
|
||||
"bytes": [axis, 0x26, 0x08, 0, 0, value & 0xFF,
|
||||
(value >> 8) & 0xFF, 0],
|
||||
}
|
||||
for axis, value in enumerate((1000, 2500, 5000, 9000))
|
||||
] + [
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x4FA",
|
||||
"frameType": "classic",
|
||||
"bytes": [0x22, 0, 0, 0, 0, 0, 0, 0],
|
||||
},
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x306",
|
||||
"frameType": "classic",
|
||||
"bytes": [1, 2, 3, 0x0F, 2, 0, 100, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(state_configured=True)
|
||||
|
||||
payload = service._synchronize_hardware_state_once(
|
||||
"conv-native-can0-state",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
passenger = payload["actuator_state"]["passenger"]
|
||||
self.assertEqual(
|
||||
{
|
||||
axis: value["position_pct"]
|
||||
for axis, value in passenger["position"].items()
|
||||
if axis in {
|
||||
"longitudinal",
|
||||
"cushion_tilt",
|
||||
"backrest_recline",
|
||||
"legrest_height",
|
||||
}
|
||||
},
|
||||
{
|
||||
"longitudinal": 10,
|
||||
"cushion_tilt": 25,
|
||||
"backrest_recline": 50,
|
||||
"legrest_height": 90,
|
||||
},
|
||||
)
|
||||
self.assertEqual(passenger["ventilation"]["level"], 1)
|
||||
self.assertEqual(passenger["heating"]["level"], 3)
|
||||
self.assertEqual(passenger["dynamic_mode"]["mode_id"], 1)
|
||||
self.assertEqual(passenger["dynamic_mode"]["state"], "running")
|
||||
self.assertTrue(
|
||||
all(
|
||||
item["feedback_source"] == "wireless_mqtt_ch0"
|
||||
for item in passenger["position"].values()
|
||||
if item.get("position_valid") is not None
|
||||
)
|
||||
)
|
||||
|
||||
def test_headunit_6a2_selector_state_maps_to_passenger_frontend_state(self) -> None:
|
||||
values = {
|
||||
1: bytes.fromhex("E0 01 E0 01"),
|
||||
2: bytes.fromhex("00 00 75 01"),
|
||||
3: bytes.fromhex("00 00 95 03"),
|
||||
4: bytes.fromhex("BF 01 87 02"),
|
||||
5: bytes.fromhex("11 00 00 00"),
|
||||
6: bytes.fromhex("11 00 00 00"),
|
||||
7: bytes.fromhex("11 00 02 00"),
|
||||
8: bytes.fromhex("11 00 00 00"),
|
||||
}
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"signals": [
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x6A2",
|
||||
"selector": selector,
|
||||
"frameType": "classic",
|
||||
"bytes": [0x7C, 0x50 + selector, 0x60, 0, *value],
|
||||
}
|
||||
for selector, value in values.items()
|
||||
],
|
||||
},
|
||||
)
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(state_configured=True)
|
||||
|
||||
payload = service._synchronize_hardware_state_once(
|
||||
"conv-headunit-selector-can0-state",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
passenger = payload["actuator_state"]["passenger"]
|
||||
self.assertEqual(
|
||||
{
|
||||
axis: passenger["position"][axis]["position_pct"]
|
||||
for axis in (
|
||||
"longitudinal",
|
||||
"cushion_tilt",
|
||||
"backrest_recline",
|
||||
"legrest_height",
|
||||
)
|
||||
},
|
||||
{
|
||||
"longitudinal": 100,
|
||||
"cushion_tilt": 0,
|
||||
"backrest_recline": 0,
|
||||
"legrest_height": 69,
|
||||
},
|
||||
)
|
||||
self.assertEqual(payload["source"], "wireless_mqtt_ch0")
|
||||
self.assertTrue(payload["complete"])
|
||||
|
||||
def test_vehicle_envelope_four_frames_decode_from_raw_can1_bytes(self) -> None:
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
@@ -544,6 +759,30 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
self.assertGreater(verification["cache_age_ms"], 50_000)
|
||||
self.assertIsNone(bus.receive(0, include_fd=True))
|
||||
|
||||
def test_outlet_switch_feedback_is_available_to_the_can1_executor_bus(self) -> None:
|
||||
bus = self.transport.bus(channel=1)
|
||||
data = bytearray(64)
|
||||
data[41:45] = bytes((0x26, 0x80, 0x80, 0x80))
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"signals": [
|
||||
{
|
||||
"channel": "can1",
|
||||
"canId": "0x4BB",
|
||||
"frameType": "fd",
|
||||
"brs": True,
|
||||
"bytes": list(data),
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
feedback = bus.receive(0, include_fd=True)
|
||||
self.assertIsNotNone(feedback)
|
||||
self.assertEqual(feedback.arbitration_id, 0x4BB)
|
||||
self.assertEqual(feedback.data[41], 0x26)
|
||||
|
||||
def test_bus_publishes_only_headunit_command_contract(self) -> None:
|
||||
bus = self.transport.bus()
|
||||
bus.set_debug_command("shade position 20")
|
||||
@@ -571,11 +810,14 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
self.assertEqual(payload["data"], "10 00 00 00 00 00 00 00")
|
||||
self.assertEqual(payload["repeat"], 1)
|
||||
self.assertEqual(payload["intervalMs"], 0)
|
||||
self.assertFalse(payload["expectResponse"])
|
||||
self.assertEqual(payload["responseTimeoutMs"], 100)
|
||||
publication = bus.published_messages[0]
|
||||
self.assertEqual(publication["topic"], topic)
|
||||
self.assertEqual(publication["qos"], qos)
|
||||
self.assertEqual(publication["retain"], retain)
|
||||
self.assertEqual(publication["delivery_status"], "published")
|
||||
self.assertEqual(publication["message_id"], 17)
|
||||
self.assertEqual(publication["payload"], payload)
|
||||
self.assertTrue(
|
||||
{
|
||||
@@ -606,6 +848,40 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
self.assertEqual(len(self.client.published), 2)
|
||||
self.assertEqual(len(bus.published_messages), 2)
|
||||
|
||||
def test_publish_returns_queued_without_waiting_for_broker_ack(self) -> None:
|
||||
class QueuedPublishResult:
|
||||
rc = 0
|
||||
mid = 29
|
||||
|
||||
@staticmethod
|
||||
def is_published() -> bool:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def wait_for_publish(timeout: float) -> bool:
|
||||
raise AssertionError(
|
||||
f"wait_for_publish({timeout}) must not be called"
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
self.client,
|
||||
"publish",
|
||||
return_value=QueuedPublishResult(),
|
||||
):
|
||||
publication = self.transport.publish(
|
||||
CanFrame(
|
||||
arbitration_id=0x431,
|
||||
data=bytes.fromhex("10 00 00 00 00 00 00 00"),
|
||||
channel=1,
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
),
|
||||
command="shade position 20",
|
||||
)
|
||||
|
||||
self.assertEqual(publication["delivery_status"], "queued")
|
||||
self.assertEqual(publication["message_id"], 29)
|
||||
|
||||
def test_can1_whitelist_publishes_rear_lock_candidate_with_debug_command(self) -> None:
|
||||
bus = self.transport.bus(channel=1)
|
||||
bus.set_debug_command("door rear lock")
|
||||
@@ -707,6 +983,10 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
for result in report["spec"]["results"]:
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(result["telemetry"]["delivery_status"], "published")
|
||||
self.assertEqual(
|
||||
result["telemetry"]["verification_status"],
|
||||
"submitted_unverified",
|
||||
)
|
||||
self.assertFalse(result["telemetry"]["feedback_required"])
|
||||
self.assertFalse(result["telemetry"]["physical_effect_confirmed"])
|
||||
self.assertEqual(len(result["telemetry"]["mqtt_messages"]), 3)
|
||||
@@ -717,6 +997,297 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_wireless_individual_outlet_plan_publishes_observed_frame_triplet(
|
||||
self,
|
||||
) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(feedback_timeout_ms=1)
|
||||
runtime = NativeCanRuntime(REPOSITORY_ROOT)
|
||||
config = runtime.build_function_config(
|
||||
"outlet",
|
||||
[
|
||||
{
|
||||
"domain": "outlet",
|
||||
"capability": "outlet.set_switch_state",
|
||||
"target": "passenger_right",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "close"},
|
||||
}
|
||||
],
|
||||
"req-wireless-outlet-switch",
|
||||
"local_rules",
|
||||
)
|
||||
plan = runtime.compile(config)
|
||||
|
||||
with patch("cognitive_agent.application.native_can_runtime.time.sleep"):
|
||||
reports = service._execute_orchestrated_reports(
|
||||
{"native_outlet": config},
|
||||
{"native_outlet": plan},
|
||||
{"native_outlet"},
|
||||
{},
|
||||
hardware_request=True,
|
||||
simulated_hardware_request=False,
|
||||
simulation_fault="normal",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
self.assertEqual(reports["native_outlet"]["spec"]["status"], "succeeded")
|
||||
payloads = [json.loads(item[1]) for item in self.client.published]
|
||||
self.assertEqual(len(payloads), 3)
|
||||
self.assertTrue(
|
||||
all(payload["command"] == "outlet passenger-right close" for payload in payloads)
|
||||
)
|
||||
self.assertTrue(all(payload["channel"] == "can1" for payload in payloads))
|
||||
self.assertTrue(all(payload["canId"] == "0x4EF" for payload in payloads))
|
||||
self.assertTrue(all(payload["frameType"] == "fd" for payload in payloads))
|
||||
self.assertTrue(all(payload["brs"] is True for payload in payloads))
|
||||
self.assertTrue(
|
||||
all(payload["data"] == "03 FF FF FF FF 08 00 00" for payload in payloads)
|
||||
)
|
||||
|
||||
def test_wireless_hardware_can1_service_skips_local_hardware_connection(self) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(
|
||||
enabled=True,
|
||||
configured=True,
|
||||
feedback_timeout_ms=1,
|
||||
authorize=lambda _token: None,
|
||||
)
|
||||
validated_events: list[dict[str, object]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
service.artifact_root = Path(temporary_directory)
|
||||
with (
|
||||
patch.object(
|
||||
service.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError(
|
||||
"wireless CAN1 must not reserve the local physical bench"
|
||||
),
|
||||
),
|
||||
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
|
||||
):
|
||||
result = service.process(
|
||||
"空调温度设置为24度",
|
||||
provider="local",
|
||||
conversation_id="conv-wireless-can1-no-local-hardware",
|
||||
execution_mode="hardware",
|
||||
control_transport="wireless_mqtt",
|
||||
_on_commands_validated=validated_events.append,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(list(result["command_plans"]), ["native_climate"])
|
||||
self.assertEqual(len(validated_events), 1)
|
||||
self.assertEqual(
|
||||
list(validated_events[0]["command_plans"]),
|
||||
["native_climate"],
|
||||
)
|
||||
payloads = [json.loads(item[1]) for item in self.client.published]
|
||||
self.assertEqual(len(payloads), 3)
|
||||
self.assertTrue(all(item["channel"] == "can1" for item in payloads))
|
||||
self.assertTrue(all(item["command"] == "ac temp driver 24" for item in payloads))
|
||||
|
||||
def test_wireless_hardware_combines_can0_and_can1_without_local_profile(self) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(
|
||||
enabled=True,
|
||||
configured=True,
|
||||
feedback_timeout_ms=1,
|
||||
authorize=lambda _token: None,
|
||||
)
|
||||
validated_events: list[dict[str, object]] = []
|
||||
seat_execution: dict[str, object] = {}
|
||||
|
||||
def execute_seat(
|
||||
plan: dict[str, object],
|
||||
config: dict[str, object],
|
||||
_state: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
seat_execution["plan"] = plan
|
||||
seat_execution["config"] = config
|
||||
operation = config["spec"]["operations"][0] # type: ignore[index]
|
||||
request_id = config["metadata"]["request_id"] # type: ignore[index]
|
||||
return {
|
||||
"apiVersion": "cognitive-agent/v1alpha1",
|
||||
"kind": "ExecutionReport",
|
||||
"metadata": {
|
||||
"id": f"report-{request_id}",
|
||||
"name": "wireless CAN0 test report",
|
||||
"version": "1.0.0",
|
||||
"request_id": request_id,
|
||||
"labels": {
|
||||
"simulation": "false",
|
||||
"seat_feedback": "wireless_mqtt_ch0",
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
"mode": "hil",
|
||||
"status": "succeeded",
|
||||
"started_at": "2026-08-25T00:00:00Z",
|
||||
"finished_at": "2026-08-25T00:00:00Z",
|
||||
"results": [
|
||||
{
|
||||
"ref": operation["id"],
|
||||
"domain": "seat",
|
||||
"status": "succeeded",
|
||||
"message": "wireless CAN0 command published",
|
||||
"telemetry": {
|
||||
"effective_position_pct": 35,
|
||||
"position_valid": True,
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
service.artifact_root = Path(temporary_directory)
|
||||
with (
|
||||
patch.object(
|
||||
type(service.hardware_settings),
|
||||
"validate_function_config",
|
||||
side_effect=AssertionError(
|
||||
"wireless mixed CAN0/CAN1 must not check combined profile"
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
service.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError(
|
||||
"wireless mixed CAN0/CAN1 must not reserve local hardware"
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
service,
|
||||
"_new_hardware_executor",
|
||||
return_value=SimpleNamespace(execute=execute_seat),
|
||||
) as executor_factory,
|
||||
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
|
||||
):
|
||||
result = service.process(
|
||||
"把副驾座椅水平位置调到35%,空调温度设置为24度",
|
||||
provider="local",
|
||||
conversation_id="conv-wireless-can0-can1-no-local-hardware",
|
||||
execution_mode="hardware",
|
||||
control_transport="wireless_mqtt",
|
||||
_on_commands_validated=validated_events.append,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
set(result["command_plans"]),
|
||||
{"seat", "native_climate"},
|
||||
)
|
||||
self.assertEqual(len(validated_events), 1)
|
||||
self.assertEqual(
|
||||
set(validated_events[0]["command_plans"]),
|
||||
{"seat", "native_climate"},
|
||||
)
|
||||
self.assertEqual(
|
||||
seat_execution["plan"]["spec"]["commands"][0]["message"], # type: ignore[index]
|
||||
"T2G_Seat_Move_Command_300",
|
||||
)
|
||||
self.assertTrue(executor_factory.call_args.kwargs["remote_transport"])
|
||||
payloads = [json.loads(item[1]) for item in self.client.published]
|
||||
self.assertEqual(len(payloads), 3)
|
||||
self.assertTrue(all(item["channel"] == "can1" for item in payloads))
|
||||
self.assertTrue(all(item["command"] == "ac temp driver 24" for item in payloads))
|
||||
|
||||
def test_wireless_fixed_controls_and_shade_preset_never_wait_for_state(
|
||||
self,
|
||||
) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(feedback_timeout_ms=1_500)
|
||||
runtime = NativeCanRuntime(REPOSITORY_ROOT)
|
||||
actions_by_domain = {
|
||||
"climate": [
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target": "driver",
|
||||
"operation": "set",
|
||||
"parameters": {"temperature_c": 24},
|
||||
},
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_fan_level",
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"level": 2},
|
||||
},
|
||||
],
|
||||
"roof": [
|
||||
{
|
||||
"domain": "roof",
|
||||
"capability": "roof.set_shade_motion",
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"motion": "open"},
|
||||
}
|
||||
],
|
||||
"ambient_light": [
|
||||
{
|
||||
"domain": "ambient_light",
|
||||
"capability": "ambient_light.set_power",
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "on"},
|
||||
}
|
||||
],
|
||||
}
|
||||
configs = {
|
||||
f"native_{domain}": runtime.build_function_config(
|
||||
domain,
|
||||
actions,
|
||||
f"req-wireless-no-wait-{domain}",
|
||||
"local_rules",
|
||||
)
|
||||
for domain, actions in actions_by_domain.items()
|
||||
}
|
||||
plans = {key: runtime.compile(config) for key, config in configs.items()}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.transport,
|
||||
"_receive_after",
|
||||
side_effect=AssertionError(
|
||||
"wireless fixed commands must not wait on can/state"
|
||||
),
|
||||
) as receive_after,
|
||||
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
|
||||
):
|
||||
reports = service._execute_orchestrated_reports(
|
||||
configs,
|
||||
plans,
|
||||
set(configs),
|
||||
{},
|
||||
hardware_request=True,
|
||||
simulated_hardware_request=False,
|
||||
simulation_fault="normal",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
receive_after.assert_not_called()
|
||||
self.assertEqual(len(self.client.published), 10)
|
||||
self.assertTrue(
|
||||
all(
|
||||
report["spec"]["status"] == "succeeded"
|
||||
for report in reports.values()
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
json.loads(payload)["expectResponse"] is False
|
||||
for _topic, payload, _qos, _retain in self.client.published
|
||||
)
|
||||
)
|
||||
|
||||
def test_model_added_nonwhitelisted_action_does_not_block_whitelisted_publish(
|
||||
self,
|
||||
) -> None:
|
||||
@@ -796,30 +1367,76 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
|
||||
def test_can0_bus_publishes_only_classic_passenger_seat_control(self) -> None:
|
||||
bus = self.transport.bus(channel=0)
|
||||
sent = bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"))
|
||||
sent = bus.send(0x300, bytes.fromhex("01 10 02 00 88 13 30 75"))
|
||||
payload = json.loads(self.client.published[-1][1])
|
||||
self.assertEqual(sent.channel, 0)
|
||||
self.assertEqual(payload["channel"], "can0")
|
||||
self.assertEqual(payload["canId"], "0x6A0")
|
||||
self.assertEqual(payload["canId"], "0x300")
|
||||
self.assertEqual(payload["data"], "01 10 02 00 88 13 30 75")
|
||||
self.assertEqual(payload["command"], "")
|
||||
self.assertEqual(payload["frameType"], "classic")
|
||||
self.assertFalse(payload["brs"])
|
||||
bus.set_debug_command("seat passenger heat low")
|
||||
bus.send(0x3CE, bytes.fromhex("10 00 00 00 00 00 00 00"))
|
||||
bus.send(0x29E, bytes.fromhex("16 00 10 00 00 00 00 00"))
|
||||
thermal_payloads = [
|
||||
json.loads(item[1]) for item in self.client.published[-2:]
|
||||
]
|
||||
self.assertEqual(
|
||||
[item["canId"] for item in thermal_payloads],
|
||||
["0x3CE", "0x29E"],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(item["command"] == "seat passenger heat low" for item in thermal_payloads)
|
||||
)
|
||||
with self.assertRaisesRegex(Exception, "0x29E thermal control payload"):
|
||||
bus.send(0x29E, bytes(8))
|
||||
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
|
||||
bus.send(0x431, bytes(8))
|
||||
with self.assertRaisesRegex(Exception, "requires classic CAN"):
|
||||
bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"),
|
||||
bus.send(0x300, bytes.fromhex("01 10 02 00 88 13 30 75"),
|
||||
is_fd=True, bitrate_switch=True)
|
||||
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
|
||||
bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"))
|
||||
with self.assertRaisesRegex(Exception, "protocol version 1"):
|
||||
bus.send(0x300, bytes(8))
|
||||
with self.assertRaisesRegex(Exception, "only STOP, MOVE, HEARTBEAT, or DYNAMIC"):
|
||||
bus.send(0x6A0, bytes.fromhex("C7 02 61 00 00 00 00 00"))
|
||||
with self.assertRaisesRegex(Exception, "target 0..1000"):
|
||||
bus.send(0x6A0, bytes.fromhex("C7 03 64 00 E9 03 00 00"))
|
||||
with self.assertRaisesRegex(Exception, "outside the semantic control whitelist"):
|
||||
bus.send(0x300, bytes.fromhex("0A 10 03 00 5A A5 78 00"))
|
||||
with self.assertRaisesRegex(Exception, "absolute move payload"):
|
||||
bus.send(0x300, bytes.fromhex("01 10 04 00 11 27 30 75"))
|
||||
with self.assertRaisesRegex(Exception, "dynamic-mode payload"):
|
||||
bus.send(0x300, bytes.fromhex("0E 10 05 00 03 00 00 00"))
|
||||
with self.assertRaisesRegex(Exception, "approved channel policy"):
|
||||
self.transport.publish(
|
||||
CanFrame(arbitration_id=0x431, data=bytes(8), channel=0)
|
||||
)
|
||||
|
||||
def test_can0_native_motion_whitelist_accepts_only_bounded_runtime_opcodes(
|
||||
self,
|
||||
) -> None:
|
||||
bus = self.transport.bus(channel=0)
|
||||
valid_payloads = (
|
||||
"01 10 01 00 88 13 30 75", # absolute 50%, 30 s
|
||||
"02 11 02 00 18 FC 30 75", # native relative -10%, 30 s
|
||||
"03 16 03 00 F4 01 84 03", # motor 2 far, 500 ms, 90%
|
||||
"08 12 04 00 00 00 00 00", # stop motor 2
|
||||
"09 10 05 00 00 00 00 00", # stop all
|
||||
"0B 13 06 00 52 4B 78 00", # motor 3 known-range home
|
||||
"0E 10 07 00 01 00 00 00", # rocking-horse
|
||||
"0F 10 08 00 00 00 00 00", # stop dynamic mode
|
||||
)
|
||||
|
||||
for payload in valid_payloads:
|
||||
with self.subTest(payload=payload):
|
||||
sent = bus.send(0x300, bytes.fromhex(payload))
|
||||
self.assertEqual(sent.arbitration_id, 0x300)
|
||||
self.assertFalse(sent.is_fd)
|
||||
|
||||
with self.assertRaisesRegex(Exception, "requires a runtime sequence"):
|
||||
bus.send(0x300, bytes.fromhex("01 10 00 00 88 13 30 75"))
|
||||
with self.assertRaisesRegex(Exception, "outside the semantic control whitelist"):
|
||||
bus.send(0x300, bytes.fromhex("0D 10 09 00 3C C3 00 00"))
|
||||
|
||||
def test_bus_receive_views_are_isolated_by_channel(self) -> None:
|
||||
can0 = self.transport.bus(channel=0)
|
||||
can1 = self.transport.bus(channel=1)
|
||||
|
||||
@@ -1,46 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.mqtt_ota_seat_executor import (
|
||||
MqttNativeSeatExecutor,
|
||||
MqttOtaSeatExecutor,
|
||||
)
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
from cognitive_agent.infrastructure.can.gateway import CanFrame
|
||||
from seat_domain import parse_local_intent
|
||||
|
||||
|
||||
def _axis_frame(axis: int, value_x100: int, *, valid: bool = True) -> CanFrame:
|
||||
flags = (1 << 3) if valid else 0
|
||||
return CanFrame(
|
||||
0x302,
|
||||
bytes(
|
||||
(
|
||||
axis,
|
||||
0,
|
||||
flags,
|
||||
0,
|
||||
0,
|
||||
value_x100 & 0xFF,
|
||||
(value_x100 >> 8) & 0xFF,
|
||||
0,
|
||||
)
|
||||
),
|
||||
channel=0,
|
||||
)
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
MAX_HALL = {0: 480, 1: 374, 2: 917, 3: 647}
|
||||
|
||||
|
||||
class _FakeOtaBus:
|
||||
def __init__(self, initial_frames: list[CanFrame]) -> None:
|
||||
self.frames = deque(initial_frames)
|
||||
class _NativeFirmwareBus:
|
||||
"""Deterministic firmware twin; it never opens or writes a physical bus."""
|
||||
|
||||
def __init__(self, matrix) -> None:
|
||||
self.matrix = matrix
|
||||
self.frames: deque[CanFrame] = deque()
|
||||
self.sent: list[CanFrame] = []
|
||||
self.debug_commands: list[str] = []
|
||||
self._debug_command = ""
|
||||
self._published_messages: list[dict[str, object]] = []
|
||||
self.closed = False
|
||||
|
||||
@property
|
||||
def published_messages(self) -> tuple[dict[str, object], ...]:
|
||||
return tuple(self._published_messages)
|
||||
|
||||
def set_debug_command(self, command: str) -> None:
|
||||
self._debug_command = command
|
||||
self.debug_commands.append(command)
|
||||
|
||||
def _frame(self, message: str, values: dict[str, int]) -> CanFrame:
|
||||
encoded = self.matrix.encode(message, values)
|
||||
return CanFrame(
|
||||
int(encoded["frame_id"], 16),
|
||||
bytes.fromhex(encoded["data_hex"]),
|
||||
channel=0,
|
||||
)
|
||||
|
||||
def _system_ack(self, sequence: int, opcode: int, ack: int) -> CanFrame:
|
||||
return self._frame(
|
||||
"T2G_Seat_Move_System_Status_301",
|
||||
{
|
||||
"Ack": ack,
|
||||
"System_State": 1,
|
||||
"Reject_Reason": 0,
|
||||
"Last_Sequence": sequence,
|
||||
"Last_Opcode": opcode,
|
||||
"Active_Mask": 0,
|
||||
"Safety_Inhibit": 0,
|
||||
"Calibration_Busy": 0,
|
||||
"Fault_Summary": 0,
|
||||
"Faulted_Mask": 0,
|
||||
"Position_Valid_Mask": 0x0F,
|
||||
"Alive": 1,
|
||||
"Firmware_Revision": 1,
|
||||
},
|
||||
)
|
||||
|
||||
def _motor_status(
|
||||
self,
|
||||
motor: int,
|
||||
*,
|
||||
sequence: int = 0,
|
||||
value_x100: int = 4_000,
|
||||
complete: bool = False,
|
||||
) -> CanFrame:
|
||||
return self._frame(
|
||||
"T2G_Seat_Move_Motor_Status_302",
|
||||
{
|
||||
"Motor_Sub_ID": motor,
|
||||
"Task_State": 6 if complete else 0,
|
||||
"Result": 2 if complete else 0,
|
||||
"Direction": 0,
|
||||
"Active": 0,
|
||||
"Position_Valid": 1,
|
||||
"Near_Endpoint": int(value_x100 == 0),
|
||||
"Far_Endpoint": int(value_x100 == 10_000),
|
||||
"Stall": 0,
|
||||
"Driver_Fault": 0,
|
||||
"Motor_Sequence": sequence,
|
||||
"Value_X100": value_x100,
|
||||
"Duty_Percent": 90,
|
||||
},
|
||||
)
|
||||
|
||||
def _diagnostic(self, motor: int, value_x100: int = 4_000) -> CanFrame:
|
||||
maximum = MAX_HALL[motor]
|
||||
hall = round(maximum * value_x100 / 10_000)
|
||||
return self._frame(
|
||||
"T2G_Seat_Move_Diagnostic_303",
|
||||
{
|
||||
"Motor_Sub_ID": motor,
|
||||
"Last_Motor_Event": 1,
|
||||
"Fault_Flags": 0,
|
||||
"Hall_Count": hall,
|
||||
"Max_Hall_Count": maximum,
|
||||
"Hall_Speed_Half_Pps": 0,
|
||||
"Retry_Count": 0,
|
||||
"Diagnostic_Alive": 1,
|
||||
},
|
||||
)
|
||||
|
||||
def _queue_preflight(self) -> None:
|
||||
for motor in range(4):
|
||||
self.frames.append(self._motor_status(motor))
|
||||
for motor in range(4):
|
||||
self.frames.append(self._diagnostic(motor))
|
||||
|
||||
def send(
|
||||
self,
|
||||
arbitration_id: int,
|
||||
@@ -57,25 +134,79 @@ class _FakeOtaBus:
|
||||
bitrate_switch=bitrate_switch,
|
||||
)
|
||||
self.sent.append(frame)
|
||||
opcode = data[2]
|
||||
job_state = (
|
||||
1 | (4 << 4) | (9 << 8)
|
||||
if opcode == 0x66
|
||||
else (1 | (3 << 4) | (7 << 8))
|
||||
if opcode in {0x64, 0x65}
|
||||
else (1 << 1) | (1 << 2)
|
||||
self._published_messages.append(
|
||||
{
|
||||
"topic": "n7/n7-headunit/can/command",
|
||||
"qos": 1,
|
||||
"retain": False,
|
||||
"payload": {
|
||||
"command": self._debug_command,
|
||||
"channel": "can0",
|
||||
"canId": f"0x{arbitration_id:03X}",
|
||||
"frameType": "classic",
|
||||
"brs": False,
|
||||
"data": data.hex(" ").upper(),
|
||||
},
|
||||
}
|
||||
)
|
||||
self.frames.append(
|
||||
CanFrame(
|
||||
0x6A2,
|
||||
bytes((0x7C, data[1], opcode, 0))
|
||||
+ job_state.to_bytes(4, "little"),
|
||||
channel=0,
|
||||
if arbitration_id == 0x300:
|
||||
opcode = data[0]
|
||||
motor = data[1] & 0x03
|
||||
sequence = int.from_bytes(data[2:4], "little")
|
||||
self.frames.append(
|
||||
self._system_ack(sequence, opcode, 4 if opcode == 0x09 else 1)
|
||||
)
|
||||
if opcode == 0x09:
|
||||
self._queue_preflight()
|
||||
elif opcode in {0x01, 0x0B}:
|
||||
target = int.from_bytes(data[4:6], "little") if opcode == 0x01 else 0
|
||||
self.frames.append(
|
||||
self._motor_status(
|
||||
motor,
|
||||
sequence=sequence,
|
||||
value_x100=target,
|
||||
complete=True,
|
||||
)
|
||||
)
|
||||
self.frames.append(self._diagnostic(motor, target))
|
||||
elif opcode == 0x03:
|
||||
direction = (data[1] >> 2) & 0x03
|
||||
target = 5_000 if direction == 1 else 3_000
|
||||
self.frames.append(
|
||||
self._motor_status(
|
||||
motor,
|
||||
sequence=sequence,
|
||||
value_x100=target,
|
||||
complete=True,
|
||||
)
|
||||
)
|
||||
self.frames.append(self._diagnostic(motor, target))
|
||||
elif opcode in {0x0E, 0x0F}:
|
||||
mode = int.from_bytes(data[4:6], "little") if opcode == 0x0E else 0
|
||||
self.frames.append(
|
||||
self._frame(
|
||||
"T2G_Seat_Dynamic_Mode_Status_306",
|
||||
{
|
||||
"Dynamic_Mode_ID": mode,
|
||||
"Dynamic_State": 1 if opcode == 0x0E else 0,
|
||||
"Dynamic_Phase": 1 if opcode == 0x0E else 0,
|
||||
"Dynamic_Active_Mask": 0x0F if opcode == 0x0E else 0,
|
||||
"Dynamic_Fault_Mask": 0,
|
||||
"Dynamic_Cycle_Index": 0,
|
||||
"Dynamic_Phase_Elapsed_Ms": 10,
|
||||
},
|
||||
)
|
||||
)
|
||||
elif arbitration_id == 0x29E:
|
||||
self.frames.append(
|
||||
self._frame(
|
||||
"T2G_Seat_Thermal_Status_4FA",
|
||||
{
|
||||
"Driver_Seat_Ventilation_Status": 1,
|
||||
"Driver_Seat_Heating_Status": 2,
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
if opcode == 0x64:
|
||||
target_x100 = int.from_bytes(data[4:8], "little") * 10
|
||||
self.frames.append(_axis_frame(data[3], target_x100))
|
||||
return frame
|
||||
|
||||
def receive(self, _timeout_ms: int, *, include_fd: bool = False) -> CanFrame | None:
|
||||
@@ -86,68 +217,226 @@ class _FakeOtaBus:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _StatusExecutor:
|
||||
def read_physical_state(self) -> dict[str, object]:
|
||||
return {"position": {}, "complete": False}
|
||||
class _PublishOnlyNativeFirmwareBus(_NativeFirmwareBus):
|
||||
"""Accept every Tx publication but expose no synchronous state feedback."""
|
||||
|
||||
def _capture_physical_state(
|
||||
self,
|
||||
_bus: _FakeOtaBus,
|
||||
_results: list[dict[str, object]],
|
||||
) -> dict[str, object]:
|
||||
return {"position": {}, "complete": True, "missing": []}
|
||||
def receive(self, _timeout_ms: int, *, include_fd: bool = False) -> CanFrame | None:
|
||||
del include_fd
|
||||
return None
|
||||
|
||||
|
||||
def _function_config(capability: str, parameters: dict[str, object]) -> dict[str, object]:
|
||||
def _function_config(
|
||||
capability: str,
|
||||
parameters: dict[str, object],
|
||||
*,
|
||||
motion: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
operation = {
|
||||
"id": "operation-1",
|
||||
"capability": capability,
|
||||
"target": {"zone": "front_right", "instance": "passenger"},
|
||||
"parameters": parameters,
|
||||
}
|
||||
if motion is not None:
|
||||
operation["motion"] = motion
|
||||
return {
|
||||
"metadata": {"request_id": "req-mqtt-ota-seat"},
|
||||
"spec": {
|
||||
"operations": [
|
||||
{
|
||||
"id": "operation-1",
|
||||
"capability": capability,
|
||||
"target": {"zone": "front_right", "instance": "passenger"},
|
||||
"parameters": parameters,
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {"request_id": "req-mqtt-native-seat"},
|
||||
"spec": {"operations": [operation]},
|
||||
}
|
||||
|
||||
|
||||
class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
def test_seat_height_language_maps_to_internal_axis_one_capability(self) -> None:
|
||||
parsed = parse_local_intent("副驾座椅高度调到60%")
|
||||
|
||||
self.assertEqual(parsed["ambiguities"], [])
|
||||
self.assertEqual(parsed["actions"][0]["axis"], "cushion_tilt")
|
||||
self.assertEqual(parsed["actions"][0]["position_pct"], 60)
|
||||
self.assertIn("目标:副驾座椅高度位置60%。", parsed["decision_summary"])
|
||||
|
||||
def test_absolute_move_uses_runtime_move_and_fresh_position_confirmation(self) -> None:
|
||||
bus = _FakeOtaBus([_axis_frame(0, 4_000)])
|
||||
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
|
||||
_StatusExecutor(),
|
||||
lambda: bus,
|
||||
)
|
||||
plan = {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "operation-1",
|
||||
"message": "T2G_Seat_Move_Command_300",
|
||||
"signals": {"Opcode": 1, "Motor_Selector": 0},
|
||||
"completion": {
|
||||
"value": 5_000,
|
||||
"tolerance": 100,
|
||||
"timeout_ms": 1_000,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
def _motion_plan(
|
||||
opcode: int,
|
||||
motor: int,
|
||||
param_a: int,
|
||||
param_b: int,
|
||||
*,
|
||||
direction: int = 0,
|
||||
target: int = 5_000,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"id": "native-motion-1",
|
||||
"operation_ref": "operation-1",
|
||||
"message": "T2G_Seat_Move_Command_300",
|
||||
"signals": {
|
||||
"Opcode": opcode,
|
||||
"Motor_Selector": motor,
|
||||
"Direction": direction,
|
||||
"Protocol_Version": 1,
|
||||
"Sequence": 0,
|
||||
"Param_A": param_a,
|
||||
"Param_B": param_b,
|
||||
},
|
||||
"send_offset_ms": 0,
|
||||
"completion": {
|
||||
"value": target,
|
||||
"tolerance": 100,
|
||||
"timeout_ms": 1_000,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MqttNativeSeatExecutorTests(unittest.TestCase):
|
||||
def _executor(self) -> tuple[MqttNativeSeatExecutor, _NativeFirmwareBus]:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
bus = _NativeFirmwareBus(service.hardware_matrix)
|
||||
executor = service._new_hardware_executor(
|
||||
lambda: bus,
|
||||
remote_transport=True,
|
||||
)
|
||||
self.assertIsInstance(executor, MqttNativeSeatExecutor)
|
||||
self.assertIs(MqttOtaSeatExecutor, MqttNativeSeatExecutor)
|
||||
return executor, bus
|
||||
|
||||
def test_wireless_hardware_service_skips_local_profile_and_publishes_native_300(
|
||||
self,
|
||||
) -> None:
|
||||
executor, bus = self._executor()
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(
|
||||
enabled=True,
|
||||
configured=True,
|
||||
authorize=lambda _token: None,
|
||||
)
|
||||
validated_events: list[dict[str, object]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
service.artifact_root = Path(temporary_directory)
|
||||
with (
|
||||
patch.object(
|
||||
type(service.hardware_settings),
|
||||
"validate_function_config",
|
||||
side_effect=AssertionError(
|
||||
"wireless CAN0 must not check the local combined profile"
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
service.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError(
|
||||
"wireless CAN0 must not reserve the local physical bench"
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
service,
|
||||
"_new_hardware_executor",
|
||||
return_value=executor,
|
||||
) as executor_factory,
|
||||
):
|
||||
result = service.process(
|
||||
"把副驾座椅水平位置调到35%",
|
||||
provider="local",
|
||||
conversation_id="conv-wireless-native-can0",
|
||||
execution_mode="hardware",
|
||||
control_transport="wireless_mqtt",
|
||||
_on_commands_validated=validated_events.append,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(len(validated_events), 1)
|
||||
self.assertTrue(executor_factory.call_args.kwargs["remote_transport"])
|
||||
self.assertTrue(bus.sent)
|
||||
self.assertTrue(all(frame.arbitration_id != 0x6A0 for frame in bus.sent))
|
||||
move = next(frame for frame in bus.sent if frame.data[0] == 0x01)
|
||||
self.assertEqual(move.arbitration_id, 0x300)
|
||||
self.assertEqual(move.data[1] & 0x03, 0)
|
||||
self.assertEqual(int.from_bytes(move.data[4:6], "little"), 3_500)
|
||||
|
||||
def test_publish_without_feedback_is_orchestration_success_not_vehicle_state(
|
||||
self,
|
||||
) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
bus = _PublishOnlyNativeFirmwareBus(service.hardware_matrix)
|
||||
executor = service._new_hardware_executor(
|
||||
lambda: bus,
|
||||
remote_transport=True,
|
||||
)
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(
|
||||
enabled=True,
|
||||
configured=True,
|
||||
authorize=lambda _token: None,
|
||||
)
|
||||
conversation_id = "conv-wireless-submit-no-feedback"
|
||||
before = service.conversations.snapshot(conversation_id)["devices"][
|
||||
"passenger"
|
||||
]["ventilation"]
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
service.artifact_root = Path(temporary_directory)
|
||||
with patch.object(
|
||||
service,
|
||||
"_new_hardware_executor",
|
||||
return_value=executor,
|
||||
):
|
||||
result = service.process(
|
||||
"打开副驾座椅通风",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
execution_mode="hardware",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(result["execution_outcome"], "submitted_unverified")
|
||||
self.assertEqual(
|
||||
result["execution_report"]["spec"]["verification_status"],
|
||||
"submitted_unverified",
|
||||
)
|
||||
operation = result["execution_report"]["spec"]["results"][-1]
|
||||
self.assertEqual(operation["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
operation["telemetry"]["verification_status"],
|
||||
"submitted_unverified",
|
||||
)
|
||||
self.assertFalse(operation["telemetry"]["physical_effect_confirmed"])
|
||||
self.assertEqual(
|
||||
result["actuator_state"]["passenger"]["ventilation"],
|
||||
before,
|
||||
)
|
||||
|
||||
def test_publish_only_dynamic_sends_requested_frame_after_sequence_barrier(
|
||||
self,
|
||||
) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
bus = _PublishOnlyNativeFirmwareBus(service.hardware_matrix)
|
||||
executor = service._new_hardware_executor(
|
||||
lambda: bus,
|
||||
remote_transport=True,
|
||||
)
|
||||
plan = _motion_plan(0x0E, 0, 2, 0, target=2)
|
||||
plan["spec"]["commands"][0]["completion"].update(
|
||||
{"accepted_states": [1, 2], "timeout_ms": 1_000}
|
||||
)
|
||||
|
||||
report = executor.execute(
|
||||
plan,
|
||||
_function_config("seat.start_dynamic_mode", {"mode_id": 2}),
|
||||
)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(report["spec"]["verification_status"], "submitted_unverified")
|
||||
self.assertEqual([frame.data[0] for frame in bus.sent], [0x09, 0x0E])
|
||||
requested = bus.sent[-1]
|
||||
self.assertEqual(requested.arbitration_id, 0x300)
|
||||
self.assertEqual(int.from_bytes(requested.data[4:6], "little"), 2)
|
||||
result = report["spec"]["results"][-1]
|
||||
self.assertFalse(result["telemetry"]["feedback_required"])
|
||||
self.assertFalse(result["telemetry"]["physical_effect_confirmed"])
|
||||
self.assertEqual(
|
||||
[item["payload"]["canId"] for item in result["telemetry"]["mqtt_messages"]],
|
||||
["0x300", "0x300"],
|
||||
)
|
||||
|
||||
def test_absolute_move_uses_native_300_publish_only_contract(self) -> None:
|
||||
executor, bus = self._executor()
|
||||
report = executor.execute(
|
||||
_motion_plan(0x01, 0, 5_000, 30_000),
|
||||
_function_config(
|
||||
"seat.set_longitudinal_position",
|
||||
{"position_pct": 50},
|
||||
@@ -155,130 +444,160 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(bus.sent[0].arbitration_id, 0x6A0)
|
||||
self.assertEqual(bus.sent[0].data[2], 0x64)
|
||||
self.assertEqual(bus.sent[0].data[3], 0)
|
||||
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 500)
|
||||
self.assertEqual([frame.data[0] for frame in bus.sent], [0x09, 0x01])
|
||||
self.assertTrue(all(frame.arbitration_id == 0x300 for frame in bus.sent))
|
||||
move = bus.sent[-1]
|
||||
self.assertEqual(move.data[0], 0x01)
|
||||
self.assertEqual(int.from_bytes(move.data[4:6], "little"), 5_000)
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(telemetry["verification_status"], "submitted_unverified")
|
||||
self.assertFalse(telemetry["feedback_required"])
|
||||
self.assertEqual(
|
||||
bus.debug_commands,
|
||||
["seat passenger longitudinal position 50"],
|
||||
[item["payload"]["canId"] for item in telemetry["mqtt_messages"]],
|
||||
["0x300", "0x300"],
|
||||
)
|
||||
self.assertEqual(
|
||||
report["spec"]["results"][0]["telemetry"]["effective_position_pct"],
|
||||
50,
|
||||
)
|
||||
self.assertTrue(bus.closed)
|
||||
|
||||
def test_legacy_zero_target_maps_to_move_not_homing(self) -> None:
|
||||
bus = _FakeOtaBus([_axis_frame(3, 2_000)])
|
||||
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
|
||||
_StatusExecutor(),
|
||||
lambda: bus,
|
||||
)
|
||||
plan = {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "operation-1",
|
||||
"message": "T2G_Seat_Move_Command_300",
|
||||
"signals": {"Opcode": 0x0B, "Motor_Selector": 3},
|
||||
"completion": {
|
||||
"value": 0,
|
||||
"tolerance": 100,
|
||||
"timeout_ms": 1_000,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
self.assertEqual(report["metadata"]["labels"]["feedback"], "wireless_mqtt_ch0")
|
||||
|
||||
def test_zero_position_preserves_firmware_known_range_home_opcode(self) -> None:
|
||||
executor, bus = self._executor()
|
||||
report = executor.execute(
|
||||
plan,
|
||||
_motion_plan(0x0B, 3, 0x4B52, 120, target=0),
|
||||
_function_config("seat.set_legrest_height", {"position_pct": 0}),
|
||||
)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(bus.sent[0].data[2], 0x64)
|
||||
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 0)
|
||||
self.assertNotIn(0x61, [frame.data[2] for frame in bus.sent])
|
||||
self.assertNotIn(0x62, [frame.data[2] for frame in bus.sent])
|
||||
home = bus.sent[-1]
|
||||
self.assertEqual(home.arbitration_id, 0x300)
|
||||
self.assertEqual(home.data[0], 0x0B)
|
||||
self.assertEqual(int.from_bytes(home.data[4:6], "little"), 0x4B52)
|
||||
self.assertEqual(int.from_bytes(home.data[6:8], "little"), 120)
|
||||
|
||||
def test_wireless_thermal_command_fails_before_publish(self) -> None:
|
||||
bus = _FakeOtaBus([])
|
||||
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
|
||||
_StatusExecutor(),
|
||||
lambda: bus,
|
||||
def test_relative_fallback_uses_bounded_native_timed_move(self) -> None:
|
||||
executor, bus = self._executor()
|
||||
report = executor.execute(
|
||||
_motion_plan(
|
||||
0x03,
|
||||
2,
|
||||
500,
|
||||
900,
|
||||
direction=1,
|
||||
target=6,
|
||||
),
|
||||
_function_config(
|
||||
"seat.set_backrest_recline",
|
||||
{"position_pct": 50},
|
||||
motion={
|
||||
"mode": "relative",
|
||||
"direction": "increase",
|
||||
"delta_position_pct": 10,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
move = bus.sent[-1]
|
||||
self.assertEqual(move.arbitration_id, 0x300)
|
||||
self.assertEqual(move.data[0], 0x03)
|
||||
self.assertEqual((move.data[1] >> 2) & 0x03, 1)
|
||||
self.assertEqual(int.from_bytes(move.data[4:6], "little"), 500)
|
||||
self.assertEqual(int.from_bytes(move.data[6:8], "little"), 900)
|
||||
|
||||
def test_wireless_thermal_uses_native_3ce_29e(self) -> None:
|
||||
executor, bus = self._executor()
|
||||
plan = {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"id": "thermal-mode",
|
||||
"operation_ref": "operation-1",
|
||||
"message": "T2G_Seat_Thermal_Mode_3CE",
|
||||
"signals": {"Driver_Seat_Thermal_Mode": 1},
|
||||
"send_offset_ms": 0,
|
||||
},
|
||||
{
|
||||
"id": "thermal-control",
|
||||
"operation_ref": "operation-1",
|
||||
"message": "T2G_Seat_Thermal_Control_29E",
|
||||
"signals": {},
|
||||
}
|
||||
"signals": {
|
||||
"Driver_Seat_Ventilation_On": 2,
|
||||
"Driver_Seat_Ventilation_Mode": 1,
|
||||
"Driver_Seat_Heating_On": 1,
|
||||
"Driver_Seat_Ventilation_Gear": 0,
|
||||
"Driver_Seat_Heating_Gear": 1,
|
||||
},
|
||||
"send_offset_ms": 20,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch("seat_domain.hardware_executor.time.sleep"):
|
||||
report = executor.execute(
|
||||
plan,
|
||||
_function_config("seat.set_heating", {"state": "on", "level": 1}),
|
||||
)
|
||||
|
||||
report = executor.execute(
|
||||
plan,
|
||||
_function_config("seat.set_heating", {"state": "on", "level": 1}),
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual([frame.arbitration_id for frame in bus.sent], [0x3CE, 0x29E])
|
||||
self.assertEqual(bus.sent[0].data, bytes.fromhex("10 00 00 00 00 00 00 00"))
|
||||
self.assertEqual(bus.sent[1].data, bytes.fromhex("16 00 10 00 00 00 00 00"))
|
||||
result = next(
|
||||
item for item in report["spec"]["results"] if item["ref"] == "operation-1"
|
||||
)
|
||||
self.assertEqual(
|
||||
[item["payload"]["canId"] for item in result["telemetry"]["mqtt_messages"]],
|
||||
["0x3CE", "0x29E"],
|
||||
)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
self.assertIn("does not provide wireless thermal control", report["spec"]["results"][0]["message"])
|
||||
self.assertEqual(bus.sent, [])
|
||||
|
||||
def test_dynamic_semantic_modes_use_seat_ota_firmware_selectors(self) -> None:
|
||||
for semantic_mode, firmware_selector, mode_name in (
|
||||
(1, 2, "木马模式"),
|
||||
(2, 1, "动态摇篮模式"),
|
||||
):
|
||||
with self.subTest(semantic_mode=semantic_mode):
|
||||
bus = _FakeOtaBus([])
|
||||
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
|
||||
_StatusExecutor(),
|
||||
lambda: bus,
|
||||
)
|
||||
plan = {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "operation-1",
|
||||
"message": "T2G_Seat_Move_Command_300",
|
||||
"signals": {"Opcode": 0x0E, "Motor_Selector": 0},
|
||||
}
|
||||
]
|
||||
def test_dynamic_modes_use_native_300_mode_ids_without_ota_selector_swap(self) -> None:
|
||||
for mode_id, mode_name in ((1, "rocking-horse"), (2, "cradle")):
|
||||
with self.subTest(mode_id=mode_id):
|
||||
executor, bus = self._executor()
|
||||
plan = _motion_plan(0x0E, 0, mode_id, 0, target=mode_id)
|
||||
plan["spec"]["commands"][0]["completion"].update(
|
||||
{
|
||||
"accepted_states": [1, 2],
|
||||
"timeout_ms": 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
)
|
||||
report = executor.execute(
|
||||
plan,
|
||||
_function_config(
|
||||
"seat.start_dynamic_mode",
|
||||
{"mode_id": semantic_mode},
|
||||
),
|
||||
_function_config("seat.start_dynamic_mode", {"mode_id": mode_id}),
|
||||
)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(bus.sent[0].data[2], 0x66)
|
||||
self.assertEqual(bus.sent[0].data[3], firmware_selector)
|
||||
self.assertEqual(
|
||||
dynamic = bus.sent[-1]
|
||||
self.assertEqual(dynamic.arbitration_id, 0x300)
|
||||
self.assertEqual(dynamic.data[0], 0x0E)
|
||||
self.assertEqual(int.from_bytes(dynamic.data[4:6], "little"), mode_id)
|
||||
self.assertIn(
|
||||
f"seat passenger dynamic {mode_name}",
|
||||
bus.debug_commands,
|
||||
[
|
||||
"seat passenger dynamic start "
|
||||
+ ("rocking-horse" if semantic_mode == 1 else "cradle")
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
int.from_bytes(bus.sent[0].data[4:8], "little"),
|
||||
300,
|
||||
)
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(telemetry["dynamic_mode_id"], semantic_mode)
|
||||
self.assertEqual(telemetry["ota_dynamic_selector"], firmware_selector)
|
||||
self.assertEqual(telemetry["dynamic_mode_name"], mode_name)
|
||||
self.assertTrue(all(frame.arbitration_id != 0x6A0 for frame in bus.sent))
|
||||
|
||||
def test_dynamic_stop_uses_native_300_opcode_0f(self) -> None:
|
||||
executor, bus = self._executor()
|
||||
plan = _motion_plan(0x0F, 0, 0, 0, target=0)
|
||||
plan["spec"]["commands"][0]["completion"].update(
|
||||
{"accepted_states": [0], "timeout_ms": 1_000}
|
||||
)
|
||||
report = executor.execute(
|
||||
plan,
|
||||
_function_config("seat.stop_dynamic_mode", {}),
|
||||
)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(len(bus.sent), 1)
|
||||
self.assertEqual(bus.sent[0].arbitration_id, 0x300)
|
||||
self.assertEqual(bus.sent[0].data[0], 0x0F)
|
||||
self.assertEqual(bus.sent[0].data[4:8], bytes(4))
|
||||
|
||||
def test_seat_height_language_maps_to_internal_axis_one_capability(self) -> None:
|
||||
parsed = parse_local_intent("副驾座椅高度调到60%")
|
||||
|
||||
self.assertEqual(parsed["ambiguities"], [])
|
||||
self.assertEqual(parsed["actions"][0]["axis"], "cushion_tilt")
|
||||
self.assertEqual(parsed["actions"][0]["position_pct"], 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -47,6 +47,45 @@ class NativeCanFeedbackTests(unittest.TestCase):
|
||||
self.assertTrue(feedback_matches("window_position:front_left:50", feedback))
|
||||
self.assertFalse(feedback_matches("window_position:front_left:80", feedback))
|
||||
|
||||
def test_individual_outlet_switch_matches_observed_4bb_signatures(self) -> None:
|
||||
data = bytearray(64)
|
||||
data[41:45] = bytes((0x26, 0x80, 0x80, 0x26))
|
||||
data[45:49] = bytes((0x2D, 0x85, 0x9E, 0xD3))
|
||||
feedback = CanFrame(0x4BB, bytes(data), channel=1, is_fd=True)
|
||||
switches = decode_feedback(feedback)["domain_state"]["outlet"][
|
||||
"switches"
|
||||
]
|
||||
|
||||
self.assertEqual(switches["driver_left"]["state"], "close")
|
||||
self.assertEqual(switches["driver_right"]["state"], "open")
|
||||
self.assertEqual(switches["passenger_right"]["state"], "close")
|
||||
self.assertTrue(
|
||||
feedback_matches("outlet_switch:driver_left:close", feedback)
|
||||
)
|
||||
self.assertFalse(
|
||||
feedback_matches("outlet_switch:driver_left:open", feedback)
|
||||
)
|
||||
|
||||
def test_individual_outlet_switch_supports_direct_37b_feedback(self) -> None:
|
||||
data = bytearray(64)
|
||||
data[0] = 0x50
|
||||
data[8] = 0b00001001 # driver-left open, driver-right close
|
||||
data[14] = 0b10010000 # passenger-left open, passenger-right close
|
||||
feedback = CanFrame(0x37B, bytes(data), channel=1, is_fd=True)
|
||||
|
||||
self.assertTrue(
|
||||
feedback_matches("outlet_switch:driver_left:open", feedback)
|
||||
)
|
||||
self.assertTrue(
|
||||
feedback_matches("outlet_switch:driver_right:close", feedback)
|
||||
)
|
||||
self.assertTrue(
|
||||
feedback_matches("outlet_switch:passenger_left:open", feedback)
|
||||
)
|
||||
self.assertTrue(
|
||||
feedback_matches("outlet_switch:passenger_right:close", feedback)
|
||||
)
|
||||
|
||||
def test_light_feedback_requires_target_value_not_only_frame_id(self) -> None:
|
||||
passive_feedback = self.frame(0x42E, "11140a03880200c6")
|
||||
self.assertEqual(
|
||||
|
||||
@@ -111,7 +111,7 @@ class NativeCanIntegrationTests(unittest.TestCase):
|
||||
self.assertTrue(command["signals"]["is_fd"])
|
||||
self.assertTrue(command["signals"]["bitrate_switch"])
|
||||
|
||||
def test_executor_requires_configured_feedback_for_vehicle_success(self) -> None:
|
||||
def test_executor_distinguishes_submission_from_vehicle_feedback(self) -> None:
|
||||
config, plan = self._compile(
|
||||
"climate",
|
||||
[
|
||||
@@ -139,7 +139,14 @@ class NativeCanIntegrationTests(unittest.TestCase):
|
||||
report = NativeCanExecutor(
|
||||
lambda: no_feedback_bus, feedback_timeout_ms=1
|
||||
).execute(self.runtime, plan, config)
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
report["spec"]["verification_status"],
|
||||
"submitted_unverified",
|
||||
)
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(telemetry["delivery_status"], "submitted")
|
||||
self.assertFalse(telemetry["physical_effect_confirmed"])
|
||||
|
||||
def test_executor_ignores_stale_wrong_value_until_target_feedback_arrives(self) -> None:
|
||||
config, plan = self._compile(
|
||||
|
||||
@@ -165,8 +165,10 @@ def test_vehicle_device_status_cards_prioritize_seats_and_expose_manual_controls
|
||||
|
||||
assert 'data-device-control-toggle="${device.id}"' in console
|
||||
assert 'data-device-control-apply="${device.id}"' in console
|
||||
assert 'new CustomEvent("cognitive-agent:vehicle-control-request"' in console
|
||||
assert "window.CognitiveAgentConsole?.executeUtterance?.(utterance" in console
|
||||
assert 'new CustomEvent("cognitive-agent:vehicle-can-control-request"' in console
|
||||
assert "window.CognitiveAgentConsole?.executeCanCommands" in console
|
||||
assert "device.commands(values).filter(Boolean)" in console
|
||||
assert 'controllable: false' in console
|
||||
assert '.v2-device-control-row input[type="range"]' in styles
|
||||
assert ".v2-device-control-select" in styles
|
||||
assert ".v2-device-control-apply" in styles
|
||||
@@ -229,11 +231,12 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
|
||||
"light ambient brightness ${level}",
|
||||
"shade position ${position}",
|
||||
"seat passenger ${axis[1]} position ${position}",
|
||||
"seat passenger dynamic start ${dynamic.name}",
|
||||
"seat passenger dynamic ${dynamic}",
|
||||
"seat passenger dynamic stop",
|
||||
"otaCan0Frame(0x64, axis[2], targetPermille)",
|
||||
"otaCan0Frame(0x66, dynamic.selector, 300)",
|
||||
"otaCan0Frame(0x63, 0xFF, 0)",
|
||||
"nativeCan0Frame(0x01, axis[2], 0, position * 100, 30000)",
|
||||
"nativeCan0Frame(0x0B, axis[2], 0, 0x4B52, 120)",
|
||||
"nativeCan0Frame(0x0E, 0, 0, mode, 0)",
|
||||
"nativeCan0Frame(0x0F, 0, 0, 0, 0)",
|
||||
):
|
||||
assert command_contract in console
|
||||
|
||||
@@ -261,6 +264,7 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
|
||||
"capability": "seat.set_backrest_recline",
|
||||
"target": {"zone": "front_right"},
|
||||
"parameters": {"position_pct": 36, "angle_deg": 120},
|
||||
"parse_source": "local_rule",
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -272,6 +276,7 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
|
||||
"capability": "climate.set_temperature",
|
||||
"target": {"zone": "driver"},
|
||||
"parameters": {"temperature_c": 24},
|
||||
"parse_source": "cloud_supplement",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -299,12 +304,14 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
|
||||
"channel": "CAN0",
|
||||
"category": "副驾座椅靠背调节",
|
||||
"command": "seat passenger backrest position 36",
|
||||
"frame": "0x6A0 · C7 <SEQ> 64 02 68 01 00 00",
|
||||
"frame": "0x300 · 01 12 <SEQ_LO> <SEQ_HI> 10 0E 30 75",
|
||||
"parseSource": "local_rule",
|
||||
},
|
||||
{
|
||||
"channel": "CAN1",
|
||||
"category": "空调温度",
|
||||
"command": "ac temp driver 24",
|
||||
"parseSource": "cloud_supplement",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -526,3 +533,19 @@ def test_twin_device_hotspots_use_responsive_hover_and_leader_labels() -> None:
|
||||
assert "--v2-hotspot-line-angle: -150deg" in styles
|
||||
assert '.v2-twin-device-popover[data-pinned="false"]' in styles
|
||||
assert "pointer-events: none;" in styles
|
||||
|
||||
|
||||
def test_validated_can_commands_survive_same_task_running_poll_and_keep_error_detail() -> None:
|
||||
console = source("console-v2.js")
|
||||
app = source("app.js")
|
||||
|
||||
assert "function validatedCommandsBelongToTask(task = {})" in console
|
||||
assert "if (!validatedCommandsBelongToTask(task))" in console
|
||||
assert "A stale" in console
|
||||
assert "must not erase those commands" in console
|
||||
assert "rememberValidatedCommandContext(result);" in console
|
||||
assert "resetValidatedCommandContext();" in console
|
||||
assert "error.detail || error.message" in console
|
||||
assert "failed_or_blocked: \"失败或已阻止\"" in console
|
||||
assert "requestError.outcome = phaseEvent.outcome;" in app
|
||||
assert "requestError.detail = phaseEvent.detail;" in app
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.application.native_can_runtime import NativeCanRuntime
|
||||
from cognitive_agent.generated.can_semantic_protocol import (
|
||||
CAN0_SUPPORTED_CAPABILITIES,
|
||||
CAN1_SUPPORTED_CAPABILITIES,
|
||||
PROTOCOL_SOURCE_SHA256,
|
||||
frame,
|
||||
match_semantic_command,
|
||||
payload_variant,
|
||||
)
|
||||
from seat_domain import T2GSeatCompiler
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class SharedCanSemanticProtocolTests(unittest.TestCase):
|
||||
def test_checked_in_python_and_cpp_views_are_current(self) -> None:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "tools/generate_can_semantic_protocol.py", "--check"],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||
self.assertEqual(len(PROTOCOL_SOURCE_SHA256), 64)
|
||||
|
||||
def test_generated_matcher_routes_both_channels_and_rejects_bad_axes(self) -> None:
|
||||
cases = {
|
||||
"seat passenger basin relative up 10": ("can0.seat.relative", 0),
|
||||
"seat passenger legrest position 100": ("can0.seat.position", 0),
|
||||
"ac fan 2": ("can1.climate.fan", 1),
|
||||
"outlet passenger-right close": ("can1.outlet.switch", 1),
|
||||
"seat driver horizontal forward 500": ("can1.seat.driver_axis", 1),
|
||||
}
|
||||
for command, expected in cases.items():
|
||||
with self.subTest(command=command):
|
||||
matched = match_semantic_command(command)
|
||||
self.assertIsNotNone(matched)
|
||||
assert matched is not None
|
||||
self.assertEqual((matched["id"], matched["channel"]), expected)
|
||||
for command in (
|
||||
"seat passenger basin relative forward 10",
|
||||
"seat driver height forward 500",
|
||||
"ac fan 8",
|
||||
"door vehicle lock",
|
||||
):
|
||||
with self.subTest(command=command):
|
||||
self.assertIsNone(match_semantic_command(command))
|
||||
|
||||
def test_outlet_static_payload_and_transport_have_one_generated_source(self) -> None:
|
||||
metadata = frame("can1.outlet.switch")
|
||||
self.assertEqual(metadata["frame_id"], "0x4EF")
|
||||
self.assertEqual(metadata["format"], "can_fd")
|
||||
self.assertTrue(metadata["brs"])
|
||||
self.assertEqual(metadata["repeat"], {"kind": "fixed", "count": 3, "period_ms": 100})
|
||||
self.assertEqual(metadata["feedback_ids"], ["0x4BB", "0x37B"])
|
||||
self.assertEqual(
|
||||
payload_variant("can1.outlet.switch", "passenger-right:close"),
|
||||
bytes.fromhex("03 FF FF FF FF 08 00 00"),
|
||||
)
|
||||
|
||||
runtime = NativeCanRuntime(ROOT)
|
||||
spec = runtime._outlet_switch_frame( # noqa: SLF001 - protocol contract test
|
||||
"passenger_right", {"state": "close"}
|
||||
)
|
||||
self.assertEqual(spec.frame_id, 0x4EF)
|
||||
self.assertEqual(spec.payload, bytes.fromhex("03 FF FF FF FF 08 00 00"))
|
||||
self.assertEqual(spec.repeat_count, 3)
|
||||
self.assertEqual(spec.period_ms, 100)
|
||||
self.assertEqual(spec.feedback_ids, (0x4BB, 0x37B))
|
||||
|
||||
def test_python_capability_sets_and_can0_plan_contract_are_generated(self) -> None:
|
||||
runtime = NativeCanRuntime(ROOT)
|
||||
self.assertEqual(runtime.supported_capabilities, CAN1_SUPPORTED_CAPABILITIES)
|
||||
self.assertIn("seat.set_longitudinal_position", CAN0_SUPPORTED_CAPABILITIES)
|
||||
contract = T2GSeatCompiler._protocol_contract( # noqa: SLF001
|
||||
"can0.seat.position", "move"
|
||||
)
|
||||
self.assertEqual(contract["frame_id"], "0x300")
|
||||
self.assertEqual(contract["repeat"]["count"], 1)
|
||||
self.assertEqual(contract["feedback_ids"], ["0x301", "0x302"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _service(tmp_path: Path) -> SeatDemoService:
|
||||
service = SeatDemoService(ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
service.openai.api_key = "offline-two-stage-test-key"
|
||||
return service
|
||||
|
||||
|
||||
def _operations(result: dict) -> list[dict]:
|
||||
return [
|
||||
operation
|
||||
for config in result.get("function_configs", {}).values()
|
||||
for operation in config.get("spec", {}).get("operations", [])
|
||||
]
|
||||
|
||||
|
||||
def test_preview_validates_local_controls_without_calling_cloud(tmp_path: Path) -> None:
|
||||
service = _service(tmp_path)
|
||||
validated_events: list[dict] = []
|
||||
|
||||
with patch.object(
|
||||
service.openai,
|
||||
"interpret",
|
||||
side_effect=AssertionError("preview must not call hosted parsing"),
|
||||
) as interpret:
|
||||
result = service.preview_native_can(
|
||||
"空调温度设置为24度,风量设置为2档,播放音乐",
|
||||
provider="openai",
|
||||
conversation_id="conv-two-stage-local-preview",
|
||||
execution_mode="dry_run",
|
||||
on_commands_validated=validated_events.append,
|
||||
)
|
||||
|
||||
interpret.assert_not_called()
|
||||
assert result["phase"] == "deterministic_preview"
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["supplement_required"] is True
|
||||
assert result["unresolved_clauses"] == ["播放音乐"]
|
||||
assert [item["capability"] for item in _operations(result)] == [
|
||||
"climate.set_temperature",
|
||||
"climate.set_fan_level",
|
||||
]
|
||||
assert {item["parse_source"] for item in _operations(result)} == {
|
||||
"local_rule"
|
||||
}
|
||||
assert len(validated_events) == 1
|
||||
assert validated_events[0]["phase"] == "deterministic_commands_validated"
|
||||
assert validated_events[0]["status"] == "processing"
|
||||
assert validated_events[0]["execution_report"] is None
|
||||
assert _operations(validated_events[0]) == _operations(result)
|
||||
assert validated_events[0]["command_plans"]
|
||||
|
||||
|
||||
def test_fully_local_cabin_request_streams_all_commands_without_supplement(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = _service(tmp_path)
|
||||
validated_events: list[dict] = []
|
||||
|
||||
with patch.object(
|
||||
service.openai,
|
||||
"interpret",
|
||||
side_effect=AssertionError("fully local request must not call GPT"),
|
||||
):
|
||||
result = service.preview_native_can(
|
||||
"空调24度、风量2档,遮阳帘打开30%,打开氛围灯",
|
||||
provider="openai",
|
||||
conversation_id="conv-two-stage-all-local",
|
||||
execution_mode="dry_run",
|
||||
on_commands_validated=validated_events.append,
|
||||
)
|
||||
|
||||
operations = _operations(result)
|
||||
assert result["supplement_required"] is False
|
||||
assert [item["capability"] for item in operations] == [
|
||||
"climate.set_temperature",
|
||||
"climate.set_fan_level",
|
||||
"roof.set_shade_position",
|
||||
"ambient_light.set_power",
|
||||
]
|
||||
assert operations[2]["parameters"]["position_pct"] == 30
|
||||
assert {item["parse_source"] for item in operations} == {"local_rule"}
|
||||
assert len(validated_events) == 1
|
||||
assert _operations(validated_events[0]) == operations
|
||||
|
||||
|
||||
def test_supplement_receives_only_unresolved_text_and_deduplicates_local_control(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = _service(tmp_path)
|
||||
preview = service.preview_native_can(
|
||||
"观影时空调温度设置为24度并播放音乐",
|
||||
provider="openai",
|
||||
conversation_id="conv-two-stage-deduplicate",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
assert [item["capability"] for item in _operations(preview)] == [
|
||||
"climate.set_temperature"
|
||||
]
|
||||
|
||||
model_output = {
|
||||
"detected_domains": ["climate", "ambient_light"],
|
||||
"requests": [
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "driver",
|
||||
"operation": "set",
|
||||
"duration_ms": None,
|
||||
"parameters": [{"name": "temperature_c", "value": 24}],
|
||||
"reason": "模型重复给出了本地温度动作。",
|
||||
},
|
||||
{
|
||||
"domain": "ambient_light",
|
||||
"capability": "ambient_light.set_power",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"duration_ms": None,
|
||||
"parameters": [{"name": "state", "value": "on"}],
|
||||
"reason": "模型补充观影氛围灯。",
|
||||
},
|
||||
],
|
||||
"ambiguities": [],
|
||||
"decision_summary": ["仅补充本地规则未覆盖的观影含义。"],
|
||||
"confidence": 0.9,
|
||||
}
|
||||
with patch.object(
|
||||
service.openai,
|
||||
"interpret",
|
||||
return_value=model_output,
|
||||
) as interpret:
|
||||
result = service.supplement_native_can(
|
||||
preview["request_id"],
|
||||
provider="openai",
|
||||
)
|
||||
|
||||
assert interpret.call_args.args[0] == "观影时空调温度设置为24度并播放音乐"
|
||||
operations = _operations(result)
|
||||
assert [item["capability"] for item in operations].count(
|
||||
"climate.set_temperature"
|
||||
) == 1
|
||||
assert [item["capability"] for item in operations].count(
|
||||
"ambient_light.set_power"
|
||||
) == 1
|
||||
assert {
|
||||
item["capability"]: item["parse_source"] for item in operations
|
||||
} == {
|
||||
"climate.set_temperature": "local_rule",
|
||||
"ambient_light.set_power": "cloud_supplement",
|
||||
}
|
||||
cloud_results = (
|
||||
result["phase_execution_reports"]["cloud_supplement"] or {}
|
||||
).get("spec", {}).get("results", [])
|
||||
assert len(cloud_results) == 1
|
||||
|
||||
|
||||
def test_frontend_two_stage_contract_and_source_badges() -> None:
|
||||
app = (ROOT / "apps" / "web-console" / "app.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
console = (ROOT / "apps" / "web-console" / "console-v2.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
styles = (ROOT / "apps" / "web-console" / "console-v2.css").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
server = (ROOT / "src" / "cognitive_agent" / "server.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert '"/api/requests/preview"' in app
|
||||
assert "`/api/requests/${preview.request_id}/supplement`" in app
|
||||
assert "renderResult(preview);" in app
|
||||
assert 'Accept: "application/x-ndjson"' in app
|
||||
assert '"cognitive-agent:validated-can-commands"' in app
|
||||
assert "本地规则命令已执行;云端补充失败" in app
|
||||
assert 'class="v2-can-command-source"' in console
|
||||
assert 'item.parseSource === "direct_control" ? "设备控件"' in console
|
||||
assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console
|
||||
assert '"cognitive-agent:validated-can-commands"' in console
|
||||
assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles
|
||||
assert '"/api/can/commands"' in server
|
||||
assert '"type": "validated_commands"' in server
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
def _report(domain: str, request_id: str) -> dict[str, object]:
|
||||
return {
|
||||
"apiVersion": "cognitive-agent/v1alpha1",
|
||||
"kind": "ExecutionReport",
|
||||
"metadata": {
|
||||
"id": f"report-{domain}-{request_id}",
|
||||
"name": f"{domain} test report",
|
||||
"version": "1.0.0",
|
||||
"request_id": request_id,
|
||||
"labels": {"simulation": "false"},
|
||||
},
|
||||
"spec": {
|
||||
"mode": "hil",
|
||||
"status": "succeeded",
|
||||
"results": [
|
||||
{
|
||||
"ref": f"{domain}-operation",
|
||||
"domain": domain,
|
||||
"status": "succeeded",
|
||||
"message": "test",
|
||||
"telemetry": {},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _ChannelBus:
|
||||
def __init__(self, channel: int) -> None:
|
||||
self.channel = channel
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Bridge:
|
||||
def __init__(self) -> None:
|
||||
self.buses = {0: _ChannelBus(0), 1: _ChannelBus(1)}
|
||||
self.closed = False
|
||||
|
||||
def channel_bus(self, channel: int) -> _ChannelBus:
|
||||
return self.buses[channel]
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _NativeExecutor:
|
||||
def __init__(self, bus_factory, **_kwargs) -> None:
|
||||
self.bus_factory = bus_factory
|
||||
|
||||
def execute(self, _runtime, _plan, config):
|
||||
if self.bus_factory().channel != 1:
|
||||
raise AssertionError("vehicle commands must use CAN1")
|
||||
return _report(config["spec"]["domain"], config["metadata"]["request_id"])
|
||||
|
||||
|
||||
class _SeatExecutor:
|
||||
def __init__(self, bus_factory) -> None:
|
||||
self.bus_factory = bus_factory
|
||||
|
||||
def execute(self, _plan, config, _state):
|
||||
if self.bus_factory().channel != 0:
|
||||
raise AssertionError("passenger-seat commands must use CAN0")
|
||||
report = _report("seat", config["metadata"]["request_id"])
|
||||
report["spec"]["physical_state"] = {
|
||||
"source": "physical_can",
|
||||
"channel": 0,
|
||||
"complete": False,
|
||||
"missing": ["longitudinal"],
|
||||
"position": {},
|
||||
}
|
||||
return report
|
||||
|
||||
|
||||
class WiredDualChannelCanTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _linux_environment() -> dict[str, str]:
|
||||
return {
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "true",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND": "zlgcan-linux",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "true",
|
||||
"COGNITIVE_AGENT_SEAT_CAN_CHANNEL": "0",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL": "1",
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "false",
|
||||
"COGNITIVE_AGENT_ZLGCAN_PYTHON": os.sys.executable,
|
||||
"COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY": str(
|
||||
REPOSITORY_ROOT.parent / "project-code" / "canfd" / "libusbcanfd.so"
|
||||
),
|
||||
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED": "false",
|
||||
}
|
||||
|
||||
def test_runtime_reports_manual_wired_dual_channel_policy(self) -> None:
|
||||
with patch.dict(os.environ, self._linux_environment(), clear=True):
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
runtime = service.runtime_info()
|
||||
|
||||
wired = next(
|
||||
item
|
||||
for item in runtime["vehicle_transports"]["options"]
|
||||
if item["id"] == "wired_can"
|
||||
)
|
||||
self.assertEqual(
|
||||
wired["target_policy"],
|
||||
"dual_channel_wired_can0_passenger_can1_vehicle",
|
||||
)
|
||||
self.assertEqual(wired["channels"], {"seat": 0, "vehicle": 1})
|
||||
self.assertEqual(
|
||||
runtime["multi_channel_can"]["active_policy"],
|
||||
"dual_channel_wired_can0_passenger_can1_vehicle",
|
||||
)
|
||||
|
||||
def test_linked_wired_execution_routes_seat_to_can0_and_vehicle_to_can1(self) -> None:
|
||||
with patch.dict(os.environ, self._linux_environment(), clear=True):
|
||||
service = SeatDemoService(Path(REPOSITORY_ROOT))
|
||||
bridge = _Bridge()
|
||||
request_id = "req-wired-dual-channel"
|
||||
seat_config = {
|
||||
"metadata": {"request_id": request_id},
|
||||
"spec": {"domain": "seat", "operations": []},
|
||||
}
|
||||
native_config = {
|
||||
"metadata": {"request_id": request_id},
|
||||
"spec": {"domain": "climate", "operations": []},
|
||||
}
|
||||
configs = {"seat": seat_config, "native_climate": native_config}
|
||||
plans = {
|
||||
"seat": {"spec": {"commands": []}},
|
||||
"native_climate": {"spec": {"commands": []}},
|
||||
}
|
||||
captured_channel_configs = []
|
||||
|
||||
def create_bridge(channel_configs):
|
||||
captured_channel_configs.extend(channel_configs)
|
||||
return bridge
|
||||
|
||||
def create_seat_executor(bus_factory, *, remote_transport=False):
|
||||
self.assertFalse(remote_transport)
|
||||
return _SeatExecutor(bus_factory)
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED": "false"},
|
||||
clear=False,
|
||||
),
|
||||
patch.object(
|
||||
type(service.vehicle_can_settings),
|
||||
"bridge",
|
||||
side_effect=create_bridge,
|
||||
),
|
||||
patch.object(
|
||||
service,
|
||||
"_new_hardware_executor",
|
||||
side_effect=create_seat_executor,
|
||||
),
|
||||
patch(
|
||||
"cognitive_agent.application.seat_service.NativeCanExecutor",
|
||||
_NativeExecutor,
|
||||
),
|
||||
patch(
|
||||
"cognitive_agent.application.seat_service.observe_vehicle_safety",
|
||||
return_value={"stationary": True},
|
||||
),
|
||||
patch(
|
||||
"cognitive_agent.application.seat_service.require_stationary_vehicle"
|
||||
),
|
||||
):
|
||||
reports = service._execute_orchestrated_reports(
|
||||
configs,
|
||||
plans,
|
||||
{"native_climate"},
|
||||
{"devices": {}},
|
||||
hardware_request=True,
|
||||
simulated_hardware_request=False,
|
||||
simulation_fault="normal",
|
||||
control_transport="wired_can",
|
||||
)
|
||||
|
||||
self.assertEqual(set(reports), {"seat", "native_climate"})
|
||||
self.assertEqual(
|
||||
[(item.channel, item.frame_format) for item in captured_channel_configs],
|
||||
[(0, "classic_can"), (1, "can_fd")],
|
||||
)
|
||||
self.assertTrue(bridge.closed)
|
||||
|
||||
def test_wired_passenger_status_uses_monitor_only_can0_bridge(self) -> None:
|
||||
with patch.dict(os.environ, self._linux_environment(), clear=True):
|
||||
service = SeatDemoService(Path(REPOSITORY_ROOT))
|
||||
bridge = _Bridge()
|
||||
executor = SimpleNamespace(
|
||||
read_physical_state=lambda: {
|
||||
"position": {},
|
||||
"complete": False,
|
||||
"missing": ["longitudinal"],
|
||||
"captured_at": "2026-08-25T00:00:00Z",
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
type(service.vehicle_can_settings),
|
||||
"bridge",
|
||||
return_value=bridge,
|
||||
) as create_bridge,
|
||||
patch.object(
|
||||
service,
|
||||
"_new_hardware_executor",
|
||||
return_value=executor,
|
||||
) as create_executor,
|
||||
):
|
||||
result = service._synchronize_hardware_state_once(
|
||||
"conv-wired-monitor-only",
|
||||
control_transport="wired_can",
|
||||
)
|
||||
|
||||
channel_configs = create_bridge.call_args.args[0]
|
||||
self.assertTrue(create_bridge.call_args.kwargs["monitor_only"])
|
||||
self.assertEqual(
|
||||
[(item.channel, item.frame_format) for item in channel_configs],
|
||||
[(0, "classic_can")],
|
||||
)
|
||||
bus_factory = create_executor.call_args.args[0]
|
||||
self.assertEqual(bus_factory().channel, 0)
|
||||
self.assertEqual(result["source"], "physical_can")
|
||||
self.assertTrue(bridge.closed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,7 +12,10 @@ from cognitive_agent.infrastructure.can.gateway import (
|
||||
ZlgCanBridgeProcess,
|
||||
ZlgChannelConfig,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.hardware import VehicleCanSettings
|
||||
from cognitive_agent.infrastructure.can.hardware import (
|
||||
SeatHardwareSettings,
|
||||
VehicleCanSettings,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.zlgcan_linux_bridge import (
|
||||
EXPECTED_ABI_SIZES,
|
||||
Driver,
|
||||
@@ -113,6 +116,34 @@ class LinuxZlgBridgeTests(unittest.TestCase):
|
||||
self.assertTrue(settings.bridge_script.name.endswith("linux_bridge.py"))
|
||||
self.assertTrue(settings.public_info()["state_configured"])
|
||||
|
||||
def test_linux_wired_vehicle_enables_native_passenger_can0_profile(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
library = Path(directory) / "libusbcanfd.so"
|
||||
library.write_bytes(b"test")
|
||||
environment = {
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "true",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND": "zlgcan-linux",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "true",
|
||||
"COGNITIVE_AGENT_SEAT_CAN_CHANNEL": "0",
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "false",
|
||||
"COGNITIVE_AGENT_ZLGCAN_PYTHON": sys.executable,
|
||||
"COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY": str(library),
|
||||
}
|
||||
with patch.dict(os.environ, environment, clear=True):
|
||||
settings = SeatHardwareSettings.from_env(REPOSITORY_ROOT)
|
||||
self.assertTrue(settings.native_passenger_can0)
|
||||
self.assertTrue(settings.configured)
|
||||
self.assertEqual(settings.backend, "zlgcan-linux")
|
||||
self.assertEqual(settings.channel, "0")
|
||||
self.assertEqual(settings.device_type, 33)
|
||||
self.assertFalse(settings.termination_enabled)
|
||||
self.assertFalse(settings.auto_flash_enabled)
|
||||
self.assertFalse(settings.position_sync_enabled)
|
||||
self.assertEqual(
|
||||
settings.public_info()["protocol_profile"],
|
||||
"passenger-seat-native-can0-v1",
|
||||
)
|
||||
|
||||
def test_process_passes_monitor_and_trace_flags_to_bridge(self) -> None:
|
||||
process = Mock()
|
||||
process.stdout = io.StringIO('{"ok":true,"event":"ready"}\n')
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cognitive_agent_can_read_trans LANGUAGES CXX)
|
||||
|
||||
find_package(Python3 REQUIRED COMPONENTS Interpreter)
|
||||
|
||||
set(CAN_PROTOCOL_SOURCE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../../config/can_protocol/semantic_protocol.json")
|
||||
set(CAN_PROTOCOL_GENERATOR
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../generate_can_semantic_protocol.py")
|
||||
set(CAN_PROTOCOL_CPP
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/generated/can_semantic_protocol.hpp")
|
||||
set(CAN_PROTOCOL_PYTHON
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../../src/cognitive_agent/generated/can_semantic_protocol.py")
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT "${CAN_PROTOCOL_CPP}" "${CAN_PROTOCOL_PYTHON}"
|
||||
COMMAND "${Python3_EXECUTABLE}" "${CAN_PROTOCOL_GENERATOR}"
|
||||
DEPENDS "${CAN_PROTOCOL_SOURCE}" "${CAN_PROTOCOL_GENERATOR}"
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/../.."
|
||||
COMMENT "Generating shared CAN semantic protocol views"
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(generate_can_semantic_protocol
|
||||
DEPENDS "${CAN_PROTOCOL_CPP}" "${CAN_PROTOCOL_PYTHON}")
|
||||
|
||||
set(CANFD_ROOT "/home/orin/project511/project-code/canfd" CACHE PATH
|
||||
"Directory containing can_read_trans.cpp, zcan.h and libusbcanfd.so")
|
||||
|
||||
foreach(required_file can_read_trans.cpp zcan.h libusbcanfd.so)
|
||||
if(NOT EXISTS "${CANFD_ROOT}/${required_file}")
|
||||
message(FATAL_ERROR "Missing ${CANFD_ROOT}/${required_file}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
add_executable(can_read_trans main.cpp)
|
||||
add_dependencies(can_read_trans generate_can_semantic_protocol)
|
||||
target_compile_features(can_read_trans PRIVATE cxx_std_17)
|
||||
# The vendor zcan.h intentionally contains an anonymous packed structure.
|
||||
# Keep useful warnings without treating that SDK ABI declaration as our error.
|
||||
target_compile_options(can_read_trans PRIVATE -Wall -Wextra)
|
||||
target_compile_definitions(can_read_trans PRIVATE
|
||||
CANFD_LEGACY_SOURCE="${CANFD_ROOT}/can_read_trans.cpp"
|
||||
CANFD_PROJECT_DIR="${CANFD_ROOT}"
|
||||
)
|
||||
target_include_directories(can_read_trans PRIVATE "${CANFD_ROOT}")
|
||||
target_link_libraries(can_read_trans PRIVATE
|
||||
"${CANFD_ROOT}/libusbcanfd.so"
|
||||
pthread
|
||||
)
|
||||
set_target_properties(can_read_trans PROPERTIES
|
||||
BUILD_RPATH "${CANFD_ROOT}"
|
||||
INSTALL_RPATH "${CANFD_ROOT}"
|
||||
)
|
||||
|
||||
enable_testing()
|
||||
add_test(NAME can_read_trans_self_test COMMAND can_read_trans self-test)
|
||||
add_test(NAME can_read_trans_console_help COMMAND can_read_trans console --help)
|
||||
set_tests_properties(can_read_trans_console_help PROPERTIES
|
||||
PASS_REGULAR_EXPRESSION "console.*--execute"
|
||||
)
|
||||
@@ -0,0 +1,423 @@
|
||||
// Generated by tools/generate_can_semantic_protocol.py; do not edit.
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <charconv>
|
||||
#include <initializer_list>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace cognitive_agent_can_protocol {
|
||||
|
||||
inline constexpr std::string_view kProtocolVersion = "1.0.0";
|
||||
inline constexpr std::string_view kProtocolSourceSha256 = "cfb7a7eb28d14a82da70d44efece7f045a7a87a4cd549d7537dc00438c297e6e";
|
||||
|
||||
struct FrameMetadata {
|
||||
std::string_view rule_id;
|
||||
std::string_view key;
|
||||
unsigned frame_id;
|
||||
std::string_view format;
|
||||
bool brs;
|
||||
unsigned dlc;
|
||||
std::string_view template_kind;
|
||||
std::string_view base_payload_hex;
|
||||
std::string_view template_json;
|
||||
std::string_view repeat_kind;
|
||||
unsigned repeat_count;
|
||||
unsigned period_ms;
|
||||
std::string_view feedback_ids_csv;
|
||||
std::string_view feedback_policy;
|
||||
};
|
||||
|
||||
struct RuleMetadata {
|
||||
std::string_view id;
|
||||
int channel;
|
||||
std::string_view domain;
|
||||
std::string_view capabilities_csv;
|
||||
std::string_view help;
|
||||
std::string_view special_handler;
|
||||
};
|
||||
|
||||
struct StaticPayload {
|
||||
std::string_view rule_id;
|
||||
std::string_view frame_key;
|
||||
std::string_view selector;
|
||||
std::string_view payload_hex;
|
||||
};
|
||||
|
||||
inline constexpr std::array<FrameMetadata, 30> kFrames{{
|
||||
FrameMetadata{"can0.seat.position", "move", 768, "classic_can", false, 8, "can0_motion_fields", "00 10 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 10 00 00 00 00 00 00\",\"fields\":{\"motor\":{\"bit_length\":2,\"start_bit\":8,\"values\":{\"backrest\":2,\"basin\":1,\"height\":1,\"horizontal\":0,\"legrest\":3,\"longitudinal\":0}},\"opcode\":{\"absolute\":1,\"bit_length\":8,\"home_zero\":11,\"start_bit\":0},\"param_a\":{\"bit_length\":16,\"home_key\":19282,\"scale\":100,\"start_bit\":32},\"param_b\":{\"absolute_timeout_ms\":30000,\"bit_length\":16,\"home_timeout_s\":120,\"start_bit\":48},\"protocol_version\":{\"bit_length\":2,\"constant\":1,\"start_bit\":12},\"sequence\":{\"bit_length\":16,\"runtime_allocated\":true,\"start_bit\":16}}}", "fixed", 1, 0, "0x301,0x302", "ack_then_motor_terminal"},
|
||||
FrameMetadata{"can0.seat.relative", "move", 768, "classic_can", false, 8, "can0_relative_closed_loop_or_timed_fallback", "03 10 00 00 00 00 00 00", "{\"base_payload_hex\":\"03 10 00 00 00 00 00 00\",\"fields\":{\"closed_loop_opcode\":1,\"direction_values\":{\"decrease\":2,\"increase\":1},\"timed_fallback_max_delta_pct\":15,\"timed_fallback_opcode\":3}}", "fixed", 1, 0, "0x301,0x302", "fresh_position_then_ack_then_motor_terminal"},
|
||||
FrameMetadata{"can0.seat.thermal", "thermal_mode", 974, "classic_can", false, 8, "can0_heating_mode_optional", "10 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"10 00 00 00 00 00 00 00\",\"optional_when\":\"heating_on\"}", "fixed", 1, 0, "0x4FA", ""},
|
||||
FrameMetadata{"can0.seat.thermal", "thermal_control", 670, "classic_can", false, 8, "can0_thermal_bit_fields", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\"}", "fixed", 1, 0, "0x4FA", "thermal_state_match"},
|
||||
FrameMetadata{"can0.seat.dynamic", "dynamic", 768, "classic_can", false, 8, "can0_dynamic_fields", "0E 10 00 00 00 00 00 00", "{\"base_payload_hex\":\"0E 10 00 00 00 00 00 00\",\"fields\":{\"mode_values\":{\"cradle\":2,\"rocking-horse\":1,\"stop\":0},\"start_opcode\":14,\"stop_opcode\":15}}", "fixed", 1, 0, "0x301,0x306", "ack_then_dynamic_state"},
|
||||
FrameMetadata{"can1.door.rear_lock", "command", 811, "classic_can", false, 8, "bit_fields", "0A 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"0A 00 00 00 00 00 00 00\",\"fields\":{\"state\":{\"bit_length\":2,\"start_bit\":14,\"values\":{\"lock\":1,\"unlock\":2}}}}", "fixed", 1, 0, "0x407", "rear_lock_state_match"},
|
||||
FrameMetadata{"can1.window.motion", "command", 1073, "can_fd", true, 8, "window_motion_bit_fields", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\",\"fields\":{\"motion_values\":{\"close\":2,\"open\":1,\"stop\":3},\"starts\":[48,56,51,59]}}", "fixed", 3, 100, "0x388", "window_state_match"},
|
||||
FrameMetadata{"can1.window.position", "command", 1073, "can_fd", true, 8, "window_feedback_closed_loop", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\"}", "special", 1, 0, "0x388", "fresh_position_then_motion_then_stop"},
|
||||
FrameMetadata{"can1.climate.power", "command", 478, "classic_can", false, 8, "bit_fields", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\",\"fields\":{\"state\":{\"bit_length\":2,\"start_bit\":28,\"values\":{\"off\":2,\"on\":1}}}}", "fixed", 3, 100, "0x404", "climate_power_match"},
|
||||
FrameMetadata{"can1.climate.fan", "command", 478, "classic_can", false, 8, "bit_fields", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\",\"fields\":{\"level\":{\"bit_length\":4,\"start_bit\":4}}}", "fixed", 3, 100, "0x404", "climate_fan_match"},
|
||||
FrameMetadata{"can1.climate.mode", "command", 478, "classic_can", false, 8, "bit_fields", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\",\"fields\":{\"mode\":{\"bit_length\":4,\"start_bit\":0,\"values\":{\"defrost\":5,\"face\":1,\"face-defrost\":7,\"face-feet\":2,\"face-feet-defrost\":6,\"feet\":3,\"feet-defrost\":4}}}}", "fixed", 3, 100, "0x404", "climate_mode_match"},
|
||||
FrameMetadata{"can1.climate.temperature", "front", 478, "classic_can", false, 8, "climate_temperature", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\",\"fields\":{\"starts\":{\"driver\":32,\"passenger\":40}}}", "fixed", 3, 100, "0x404", "climate_temperature_match"},
|
||||
FrameMetadata{"can1.climate.temperature", "rear", 478, "classic_can", false, 8, "climate_temperature", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\",\"fields\":{\"starts\":{\"rear\":48}}}", "fixed", 1, 100, "0x404", "climate_temperature_match"},
|
||||
FrameMetadata{"can1.climate.control", "command", 478, "classic_can", false, 8, "bit_fields", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\",\"fields\":{\"mode\":{\"bit_length\":2,\"start_bit\":16,\"values\":{\"auto\":2,\"manual\":1}}}}", "fixed", 3, 100, "0x404", "climate_control_match"},
|
||||
FrameMetadata{"can1.climate.cycle", "command", 478, "classic_can", false, 8, "bit_fields", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\",\"fields\":{\"mode\":{\"bit_length\":2,\"start_bit\":19,\"values\":{\"external\":2,\"internal\":1}}}}", "fixed", 3, 100, "0x404", "climate_cycle_match"},
|
||||
FrameMetadata{"can1.outlet.switch", "command", 1263, "can_fd", true, 8, "static_variant", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\",\"payload_variants\":{\"driver-left:close\":\"02 FF FF FF FF 02 FF FF\",\"driver-left:open\":\"02 FF FF FF FF 01 FF FF\",\"driver-right:close\":\"02 FF FF FF FF 08 FF FF\",\"driver-right:open\":\"02 FF FF FF FF 04 FF FF\",\"passenger-left:close\":\"03 FF FF FF FF 02 00 00\",\"passenger-left:open\":\"03 FF FF FF FF 01 00 00\",\"passenger-right:close\":\"03 FF FF FF FF 08 00 00\",\"passenger-right:open\":\"03 FF FF FF FF 04 00 00\"}}", "fixed", 3, 100, "0x4BB,0x37B", "outlet_switch_state_match"},
|
||||
FrameMetadata{"can1.outlet.percent", "command", 1263, "can_fd", true, 8, "outlet_calibrated_coordinates", "00 FF FF FF FF 00 00 00", "{\"base_payload_hex\":\"00 FF FF FF FF 00 00 00\"}", "fixed", 3, 100, "0x2F5,0x37B", "outlet_position_match"},
|
||||
FrameMetadata{"can1.outlet.position", "command", 1263, "can_fd", true, 8, "outlet_calibrated_coordinates", "00 FF FF FF FF 00 00 00", "{\"base_payload_hex\":\"00 FF FF FF FF 00 00 00\"}", "fixed", 3, 100, "0x2F5,0x37B", "outlet_position_match"},
|
||||
FrameMetadata{"can1.seat.driver_thermal", "command", 1073, "can_fd", true, 8, "driver_seat_thermal", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\"}", "fixed", 3, 100, "0x4FA", "driver_seat_thermal_match"},
|
||||
FrameMetadata{"can1.seat.driver_massage", "command", 1217, "can_fd", true, 8, "driver_seat_massage", "12 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"12 00 00 00 00 00 00 00\"}", "fixed", 3, 100, "0x4FA", "driver_seat_massage_match"},
|
||||
FrameMetadata{"can1.seat.driver_axis", "motion", 1217, "can_fd", true, 8, "driver_seat_axis", "05 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"05 00 00 00 00 00 00 00\"}", "duration_ceiling", 0, 100, "0x494", "activity_then_forced_stop"},
|
||||
FrameMetadata{"can1.seat.driver_axis", "stop", 1217, "can_fd", true, 8, "static", "05 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"05 00 00 00 00 00 00 00\"}", "fixed", 3, 100, "0x494", "submitted_stop"},
|
||||
FrameMetadata{"can1.seat.driver_memory", "sequence", 1217, "can_fd", true, 8, "learned_driver_memory_pair", "16 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"16 00 00 00 00 00 00 00\"}", "sequence", 20, 50, "0x494", "fresh_initialized_status_then_post_status"},
|
||||
FrameMetadata{"can1.light.indoor", "command", 1269, "can_fd", true, 8, "reading_light_all", "00 00 03 FF FF 00 00 00", "{\"base_payload_hex\":\"00 00 03 FF FF 00 00 00\"}", "fixed", 3, 100, "0x42E", "indoor_light_match"},
|
||||
FrameMetadata{"can1.light.reading_all", "command", 1269, "can_fd", true, 8, "reading_light_all", "00 00 03 FF FF 00 00 00", "{\"base_payload_hex\":\"00 00 03 FF FF 00 00 00\"}", "fixed", 3, 100, "0x42E", "indoor_light_match"},
|
||||
FrameMetadata{"can1.light.ambient_power", "command", 1020, "can_fd", true, 8, "static_variant", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\",\"payload_variants\":{\"off\":\"00 31 00 75 00 80 00 00\",\"on\":\"00 36 00 75 00 80 00 00\"}}", "fixed", 3, 100, "0x42E", "ambient_power_match"},
|
||||
FrameMetadata{"can1.light.ambient_color", "command", 1020, "can_fd", true, 8, "ambient_color", "00 30 00 75 00 00 00 00", "{\"base_payload_hex\":\"00 30 00 75 00 00 00 00\"}", "fixed", 3, 100, "0x42E", "ambient_color_match"},
|
||||
FrameMetadata{"can1.light.ambient_brightness", "command", 1020, "can_fd", true, 8, "ambient_brightness", "00 30 00 75 00 00 00 00", "{\"base_payload_hex\":\"00 30 00 75 00 00 00 00\"}", "fixed", 3, 100, "0x42E", "ambient_brightness_match"},
|
||||
FrameMetadata{"can1.shade.motion", "command", 1073, "classic_can", false, 8, "static_variant", "00 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"00 00 00 00 00 00 00 00\",\"payload_variants\":{\"close\":\"20 00 00 00 00 00 00 00\",\"half\":\"30 00 00 00 00 00 00 00\",\"open\":\"10 00 00 00 00 00 00 00\",\"stop\":\"40 00 00 00 00 00 00 00\"}}", "fixed", 1, 0, "0x41A", "shade_position_or_stop_submission"},
|
||||
FrameMetadata{"can1.shade.position", "command", 1073, "classic_can", false, 8, "shade_feedback_closed_loop", "40 00 00 00 00 00 00 00", "{\"base_payload_hex\":\"40 00 00 00 00 00 00 00\"}", "special", 1, 0, "0x41A", "fresh_position_then_motion_then_stop"}
|
||||
}};
|
||||
|
||||
inline constexpr std::array<RuleMetadata, 27> kRules{{
|
||||
RuleMetadata{"can0.seat.position", 0, "seat", "seat.set_longitudinal_position,seat.set_cushion_tilt,seat.set_backrest_recline,seat.set_legrest_height", "seat passenger <horizontal|basin|backrest|legrest> position <0-100>", "can0_absolute_position"},
|
||||
RuleMetadata{"can0.seat.relative", 0, "seat", "seat.set_longitudinal_position,seat.set_cushion_tilt,seat.set_backrest_recline,seat.set_legrest_height", "seat passenger <axis> relative <direction> <1-100>", "can0_relative_position"},
|
||||
RuleMetadata{"can0.seat.thermal", 0, "seat", "seat.set_ventilation,seat.set_heating", "seat passenger <vent|heat> <off|low|medium|high>", "can0_thermal_preserve_peer_feature"},
|
||||
RuleMetadata{"can0.seat.dynamic", 0, "seat", "seat.start_dynamic_mode,seat.stop_dynamic_mode", "seat passenger dynamic <rocking-horse|cradle|stop>", "can0_dynamic_mode"},
|
||||
RuleMetadata{"can1.door.rear_lock", 1, "door", "door.set_rear_lock", "door rear <lock|unlock>", ""},
|
||||
RuleMetadata{"can1.window.motion", 1, "window", "window.set_motion", "window <all|front|rear|front-left|front-right|rear-left|rear-right> <open|close|stop>", ""},
|
||||
RuleMetadata{"can1.window.position", 1, "window", "window.set_position", "window <front-left|front-right|rear-left|rear-right> position <0-100>", "window_position"},
|
||||
RuleMetadata{"can1.climate.power", 1, "climate", "climate.set_power,climate.set_ac", "ac <on|off>", ""},
|
||||
RuleMetadata{"can1.climate.fan", 1, "climate", "climate.set_fan_level", "ac fan <1-7>", ""},
|
||||
RuleMetadata{"can1.climate.mode", 1, "climate", "climate.set_outlet_mode", "ac mode <face|face-feet|feet|feet-defrost|defrost|face-feet-defrost|face-defrost>", ""},
|
||||
RuleMetadata{"can1.climate.temperature", 1, "climate", "climate.set_temperature", "ac temp <driver|passenger|rear> <18-32>", ""},
|
||||
RuleMetadata{"can1.climate.control", 1, "climate", "climate.set_control_mode", "ac control <manual|auto>", ""},
|
||||
RuleMetadata{"can1.climate.cycle", 1, "climate", "climate.set_cycle_mode", "ac cycle <internal|external>", ""},
|
||||
RuleMetadata{"can1.outlet.switch", 1, "outlet", "outlet.set_switch_state", "outlet <driver-left|driver-right|passenger-left|passenger-right> <open|close>", ""},
|
||||
RuleMetadata{"can1.outlet.percent", 1, "outlet", "outlet.set_position_percent", "outlet <target> percent <0-100> <0-100>", "outlet_percent_to_calibrated_position"},
|
||||
RuleMetadata{"can1.outlet.position", 1, "outlet", "outlet.set_calibrated_position", "outlet <target> position <-128..126> <-128..126>", "outlet_calibrated_position"},
|
||||
RuleMetadata{"can1.seat.driver_thermal", 1, "seat", "seat.set_driver_ventilation_native,seat.set_driver_heating_native", "seat driver <vent|heat> <off|low|high>", ""},
|
||||
RuleMetadata{"can1.seat.driver_massage", 1, "seat", "seat.set_driver_massage_native", "seat driver massage <off|strength weak|medium|strong|mode wave|pulse|stretch|slack|relax>", ""},
|
||||
RuleMetadata{"can1.seat.driver_axis", 1, "seat", "seat.set_driver_axis_native", "seat driver <horizontal|height|backrest|basin> <direction|stop> [100-2000]", "driver_seat_axis"},
|
||||
RuleMetadata{"can1.seat.driver_memory", 1, "seat", "seat.set_driver_position_native,seat.recall_driver_memory_native", "seat driver position <1-3> | seat driver memory recall <1-3>", "driver_seat_memory_recall"},
|
||||
RuleMetadata{"can1.light.indoor", 1, "light", "interior_light.set_indoor_power", "light indoor <on|off>", ""},
|
||||
RuleMetadata{"can1.light.reading_all", 1, "light", "interior_light.set_reading_power", "light reading all <on|off>", ""},
|
||||
RuleMetadata{"can1.light.ambient_power", 1, "light", "ambient_light.set_power", "light ambient <on|off>", ""},
|
||||
RuleMetadata{"can1.light.ambient_color", 1, "light", "ambient_light.set_color_index", "light ambient color <1-127>", ""},
|
||||
RuleMetadata{"can1.light.ambient_brightness", 1, "light", "ambient_light.set_brightness_level", "light ambient brightness <1-5>", ""},
|
||||
RuleMetadata{"can1.shade.motion", 1, "roof", "roof.set_shade_motion", "shade <open|close|half|stop>", "shade_preset"},
|
||||
RuleMetadata{"can1.shade.position", 1, "roof", "roof.set_shade_position", "shade position <0-100>", "shade_position"}
|
||||
}};
|
||||
|
||||
inline constexpr std::array<StaticPayload, 14> kStaticPayloads{{
|
||||
StaticPayload{"can1.outlet.switch", "command", "driver-left:open", "02 FF FF FF FF 01 FF FF"},
|
||||
StaticPayload{"can1.outlet.switch", "command", "driver-left:close", "02 FF FF FF FF 02 FF FF"},
|
||||
StaticPayload{"can1.outlet.switch", "command", "driver-right:open", "02 FF FF FF FF 04 FF FF"},
|
||||
StaticPayload{"can1.outlet.switch", "command", "driver-right:close", "02 FF FF FF FF 08 FF FF"},
|
||||
StaticPayload{"can1.outlet.switch", "command", "passenger-left:open", "03 FF FF FF FF 01 00 00"},
|
||||
StaticPayload{"can1.outlet.switch", "command", "passenger-left:close", "03 FF FF FF FF 02 00 00"},
|
||||
StaticPayload{"can1.outlet.switch", "command", "passenger-right:open", "03 FF FF FF FF 04 00 00"},
|
||||
StaticPayload{"can1.outlet.switch", "command", "passenger-right:close", "03 FF FF FF FF 08 00 00"},
|
||||
StaticPayload{"can1.light.ambient_power", "command", "on", "00 36 00 75 00 80 00 00"},
|
||||
StaticPayload{"can1.light.ambient_power", "command", "off", "00 31 00 75 00 80 00 00"},
|
||||
StaticPayload{"can1.shade.motion", "command", "open", "10 00 00 00 00 00 00 00"},
|
||||
StaticPayload{"can1.shade.motion", "command", "close", "20 00 00 00 00 00 00 00"},
|
||||
StaticPayload{"can1.shade.motion", "command", "half", "30 00 00 00 00 00 00 00"},
|
||||
StaticPayload{"can1.shade.motion", "command", "stop", "40 00 00 00 00 00 00 00"}
|
||||
}};
|
||||
|
||||
inline const RuleMetadata* find_rule(std::string_view id) {
|
||||
for (const auto& item : kRules)
|
||||
if (item.id == id)
|
||||
return &item;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
inline const FrameMetadata* find_frame(std::string_view rule_id,
|
||||
std::string_view key = "command") {
|
||||
for (const auto& item : kFrames)
|
||||
if (item.rule_id == rule_id && item.key == key)
|
||||
return &item;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
inline const StaticPayload* find_static_payload(std::string_view rule_id,
|
||||
std::string_view selector,
|
||||
std::string_view key = "command") {
|
||||
for (const auto& item : kStaticPayloads)
|
||||
if (item.rule_id == rule_id && item.frame_key == key &&
|
||||
item.selector == selector)
|
||||
return &item;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
inline bool protocol_contains(std::string_view value,
|
||||
std::initializer_list<std::string_view> allowed) {
|
||||
for (const auto candidate : allowed)
|
||||
if (value == candidate)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
inline bool protocol_integer_in_range(std::string_view text, int minimum,
|
||||
int maximum) {
|
||||
int value = 0;
|
||||
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
return result.ec == std::errc{} && result.ptr == text.data() + text.size() &&
|
||||
std::to_string(value) == text && value >= minimum && value <= maximum;
|
||||
}
|
||||
|
||||
inline const RuleMetadata* match_semantic_command(
|
||||
const std::vector<std::string>& words, int channel = -1) {
|
||||
if ((channel < 0 || channel == 0) &&
|
||||
((words.size() == 5 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "passenger" &&
|
||||
protocol_contains(words[2], {"horizontal", "longitudinal", "basin", "height", "backrest", "legrest"}) &&
|
||||
words[3] == "position" &&
|
||||
protocol_integer_in_range(words[4], 0, 100))))
|
||||
return find_rule("can0.seat.position");
|
||||
if ((channel < 0 || channel == 0) &&
|
||||
((words.size() == 6 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "passenger" &&
|
||||
protocol_contains(words[2], {"horizontal", "longitudinal"}) &&
|
||||
words[3] == "relative" &&
|
||||
protocol_contains(words[4], {"forward", "backward"}) &&
|
||||
protocol_integer_in_range(words[5], 1, 100)) ||
|
||||
(words.size() == 6 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "passenger" &&
|
||||
protocol_contains(words[2], {"basin", "height"}) &&
|
||||
words[3] == "relative" &&
|
||||
protocol_contains(words[4], {"up", "down"}) &&
|
||||
protocol_integer_in_range(words[5], 1, 100)) ||
|
||||
(words.size() == 6 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "passenger" &&
|
||||
words[2] == "backrest" &&
|
||||
words[3] == "relative" &&
|
||||
protocol_contains(words[4], {"forward", "backward"}) &&
|
||||
protocol_integer_in_range(words[5], 1, 100)) ||
|
||||
(words.size() == 6 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "passenger" &&
|
||||
words[2] == "legrest" &&
|
||||
words[3] == "relative" &&
|
||||
protocol_contains(words[4], {"up", "down"}) &&
|
||||
protocol_integer_in_range(words[5], 1, 100))))
|
||||
return find_rule("can0.seat.relative");
|
||||
if ((channel < 0 || channel == 0) &&
|
||||
((words.size() == 4 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "passenger" &&
|
||||
protocol_contains(words[2], {"vent", "heat"}) &&
|
||||
protocol_contains(words[3], {"off", "low", "medium", "high"}))))
|
||||
return find_rule("can0.seat.thermal");
|
||||
if ((channel < 0 || channel == 0) &&
|
||||
((words.size() == 4 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "passenger" &&
|
||||
words[2] == "dynamic" &&
|
||||
protocol_contains(words[3], {"rocking-horse", "cradle", "stop"})) ||
|
||||
(words.size() == 5 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "passenger" &&
|
||||
words[2] == "dynamic" &&
|
||||
words[3] == "start" &&
|
||||
protocol_contains(words[4], {"rocking-horse", "cradle"}))))
|
||||
return find_rule("can0.seat.dynamic");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 3 &&
|
||||
words[0] == "door" &&
|
||||
words[1] == "rear" &&
|
||||
protocol_contains(words[2], {"lock", "unlock"}))))
|
||||
return find_rule("can1.door.rear_lock");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 3 &&
|
||||
words[0] == "window" &&
|
||||
protocol_contains(words[1], {"all", "front", "rear", "front-left", "front-right", "rear-left", "rear-right"}) &&
|
||||
protocol_contains(words[2], {"open", "close", "stop"}))))
|
||||
return find_rule("can1.window.motion");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 4 &&
|
||||
words[0] == "window" &&
|
||||
protocol_contains(words[1], {"front-left", "front-right", "rear-left", "rear-right"}) &&
|
||||
words[2] == "position" &&
|
||||
protocol_integer_in_range(words[3], 0, 100))))
|
||||
return find_rule("can1.window.position");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 2 &&
|
||||
words[0] == "ac" &&
|
||||
protocol_contains(words[1], {"on", "off"}))))
|
||||
return find_rule("can1.climate.power");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 3 &&
|
||||
words[0] == "ac" &&
|
||||
words[1] == "fan" &&
|
||||
protocol_integer_in_range(words[2], 1, 7))))
|
||||
return find_rule("can1.climate.fan");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 3 &&
|
||||
words[0] == "ac" &&
|
||||
words[1] == "mode" &&
|
||||
protocol_contains(words[2], {"face", "face-feet", "feet", "feet-defrost", "defrost", "face-feet-defrost", "face-defrost"}))))
|
||||
return find_rule("can1.climate.mode");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 4 &&
|
||||
words[0] == "ac" &&
|
||||
words[1] == "temp" &&
|
||||
protocol_contains(words[2], {"driver", "passenger", "rear"}) &&
|
||||
protocol_integer_in_range(words[3], 18, 32))))
|
||||
return find_rule("can1.climate.temperature");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 3 &&
|
||||
words[0] == "ac" &&
|
||||
words[1] == "control" &&
|
||||
protocol_contains(words[2], {"manual", "auto"}))))
|
||||
return find_rule("can1.climate.control");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 3 &&
|
||||
words[0] == "ac" &&
|
||||
words[1] == "cycle" &&
|
||||
protocol_contains(words[2], {"internal", "external"}))))
|
||||
return find_rule("can1.climate.cycle");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 3 &&
|
||||
words[0] == "outlet" &&
|
||||
protocol_contains(words[1], {"driver-left", "driver-right", "passenger-left", "passenger-right"}) &&
|
||||
protocol_contains(words[2], {"open", "close"}))))
|
||||
return find_rule("can1.outlet.switch");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 5 &&
|
||||
words[0] == "outlet" &&
|
||||
protocol_contains(words[1], {"driver-left", "driver-right", "passenger-left", "passenger-right"}) &&
|
||||
words[2] == "percent" &&
|
||||
protocol_integer_in_range(words[3], 0, 100) &&
|
||||
protocol_integer_in_range(words[4], 0, 100))))
|
||||
return find_rule("can1.outlet.percent");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 5 &&
|
||||
words[0] == "outlet" &&
|
||||
protocol_contains(words[1], {"driver-left", "driver-right", "passenger-left", "passenger-right"}) &&
|
||||
words[2] == "position" &&
|
||||
protocol_integer_in_range(words[3], -128, 126) &&
|
||||
protocol_integer_in_range(words[4], -128, 126))))
|
||||
return find_rule("can1.outlet.position");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 4 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "driver" &&
|
||||
protocol_contains(words[2], {"vent", "heat"}) &&
|
||||
protocol_contains(words[3], {"off", "low", "high"}))))
|
||||
return find_rule("can1.seat.driver_thermal");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 4 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "driver" &&
|
||||
words[2] == "massage" &&
|
||||
words[3] == "off") ||
|
||||
(words.size() == 5 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "driver" &&
|
||||
words[2] == "massage" &&
|
||||
words[3] == "strength" &&
|
||||
protocol_contains(words[4], {"weak", "medium", "strong"})) ||
|
||||
(words.size() == 5 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "driver" &&
|
||||
words[2] == "massage" &&
|
||||
words[3] == "mode" &&
|
||||
protocol_contains(words[4], {"wave", "pulse", "stretch", "slack", "relax"}))))
|
||||
return find_rule("can1.seat.driver_massage");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 4 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "driver" &&
|
||||
protocol_contains(words[2], {"horizontal", "backrest"}) &&
|
||||
protocol_contains(words[3], {"forward", "backward"})) ||
|
||||
(words.size() == 5 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "driver" &&
|
||||
protocol_contains(words[2], {"horizontal", "backrest"}) &&
|
||||
protocol_contains(words[3], {"forward", "backward"}) &&
|
||||
protocol_integer_in_range(words[4], 100, 2000)) ||
|
||||
(words.size() == 4 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "driver" &&
|
||||
protocol_contains(words[2], {"height", "basin"}) &&
|
||||
protocol_contains(words[3], {"up", "down"})) ||
|
||||
(words.size() == 5 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "driver" &&
|
||||
protocol_contains(words[2], {"height", "basin"}) &&
|
||||
protocol_contains(words[3], {"up", "down"}) &&
|
||||
protocol_integer_in_range(words[4], 100, 2000)) ||
|
||||
(words.size() == 4 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "driver" &&
|
||||
protocol_contains(words[2], {"horizontal", "height", "backrest", "basin"}) &&
|
||||
words[3] == "stop")))
|
||||
return find_rule("can1.seat.driver_axis");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 4 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "driver" &&
|
||||
words[2] == "position" &&
|
||||
protocol_integer_in_range(words[3], 1, 3)) ||
|
||||
(words.size() == 5 &&
|
||||
words[0] == "seat" &&
|
||||
words[1] == "driver" &&
|
||||
words[2] == "memory" &&
|
||||
words[3] == "recall" &&
|
||||
protocol_integer_in_range(words[4], 1, 3))))
|
||||
return find_rule("can1.seat.driver_memory");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 3 &&
|
||||
words[0] == "light" &&
|
||||
words[1] == "indoor" &&
|
||||
protocol_contains(words[2], {"on", "off"}))))
|
||||
return find_rule("can1.light.indoor");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 4 &&
|
||||
words[0] == "light" &&
|
||||
words[1] == "reading" &&
|
||||
words[2] == "all" &&
|
||||
protocol_contains(words[3], {"on", "off"}))))
|
||||
return find_rule("can1.light.reading_all");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 3 &&
|
||||
words[0] == "light" &&
|
||||
words[1] == "ambient" &&
|
||||
protocol_contains(words[2], {"on", "off"}))))
|
||||
return find_rule("can1.light.ambient_power");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 4 &&
|
||||
words[0] == "light" &&
|
||||
words[1] == "ambient" &&
|
||||
words[2] == "color" &&
|
||||
protocol_integer_in_range(words[3], 1, 127))))
|
||||
return find_rule("can1.light.ambient_color");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 4 &&
|
||||
words[0] == "light" &&
|
||||
words[1] == "ambient" &&
|
||||
words[2] == "brightness" &&
|
||||
protocol_integer_in_range(words[3], 1, 5))))
|
||||
return find_rule("can1.light.ambient_brightness");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 2 &&
|
||||
words[0] == "shade" &&
|
||||
protocol_contains(words[1], {"open", "close", "half", "stop"}))))
|
||||
return find_rule("can1.shade.motion");
|
||||
if ((channel < 0 || channel == 1) &&
|
||||
((words.size() == 3 &&
|
||||
words[0] == "shade" &&
|
||||
words[1] == "position" &&
|
||||
protocol_integer_in_range(words[2], 0, 100))))
|
||||
return find_rule("can1.shade.position");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace cognitive_agent_can_protocol
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the shared CAN semantic protocol and generate Python/C++ views."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import pprint
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_SOURCE = ROOT / "config" / "can_protocol" / "semantic_protocol.json"
|
||||
DEFAULT_PYTHON = (
|
||||
ROOT / "src" / "cognitive_agent" / "generated" / "can_semantic_protocol.py"
|
||||
)
|
||||
DEFAULT_CPP = (
|
||||
ROOT / "tools" / "can_read_trans" / "generated" / "can_semantic_protocol.hpp"
|
||||
)
|
||||
|
||||
|
||||
class ProtocolError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _payload_bytes(value: str, *, where: str, dlc: int) -> bytes:
|
||||
try:
|
||||
payload = bytes.fromhex(value)
|
||||
except ValueError as exc:
|
||||
raise ProtocolError(f"{where}: invalid payload hex") from exc
|
||||
if len(payload) != dlc:
|
||||
raise ProtocolError(f"{where}: payload has {len(payload)} bytes, expected {dlc}")
|
||||
return payload
|
||||
|
||||
|
||||
def _frame_id(value: Any, *, where: str) -> int:
|
||||
if not isinstance(value, str) or not re.fullmatch(r"0x[0-9A-Fa-f]{1,3}", value):
|
||||
raise ProtocolError(f"{where}: frame ID must be 0x000..0x7FF")
|
||||
parsed = int(value, 16)
|
||||
if not 0 <= parsed <= 0x7FF:
|
||||
raise ProtocolError(f"{where}: only standard 11-bit IDs are supported")
|
||||
return parsed
|
||||
|
||||
|
||||
def load_and_validate(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ProtocolError(f"cannot load {path}: {exc}") from exc
|
||||
if document.get("schema") != "cognitive-agent-can-semantic-protocol/v1":
|
||||
raise ProtocolError("unsupported or missing protocol schema")
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+", str(document.get("version", ""))):
|
||||
raise ProtocolError("version must be semantic x.y.z")
|
||||
rules = document.get("rules")
|
||||
if not isinstance(rules, list) or not rules:
|
||||
raise ProtocolError("rules must be a non-empty list")
|
||||
|
||||
seen_rules: set[str] = set()
|
||||
capability_channels: dict[str, set[int]] = {}
|
||||
for rule_index, rule in enumerate(rules):
|
||||
where = f"rules[{rule_index}]"
|
||||
rule_id = rule.get("id")
|
||||
if not isinstance(rule_id, str) or not re.fullmatch(r"can[01]\.[a-z0-9_.]+", rule_id):
|
||||
raise ProtocolError(f"{where}: invalid id")
|
||||
if rule_id in seen_rules:
|
||||
raise ProtocolError(f"{where}: duplicate id {rule_id}")
|
||||
seen_rules.add(rule_id)
|
||||
channel = rule.get("channel")
|
||||
if channel not in {0, 1} or not rule_id.startswith(f"can{channel}."):
|
||||
raise ProtocolError(f"{where}: channel does not match rule id")
|
||||
if not isinstance(rule.get("domain"), str) or not re.fullmatch(
|
||||
r"[a-z][a-z0-9_]*", rule["domain"]
|
||||
):
|
||||
raise ProtocolError(f"{where}: invalid domain")
|
||||
if not isinstance(rule.get("help"), str) or not rule["help"].strip():
|
||||
raise ProtocolError(f"{where}: help must be non-empty")
|
||||
capabilities = rule.get("capabilities")
|
||||
if not isinstance(capabilities, list) or not capabilities or not all(
|
||||
isinstance(item, str) and "." in item for item in capabilities
|
||||
):
|
||||
raise ProtocolError(f"{where}: capabilities must be non-empty names")
|
||||
for capability in capabilities:
|
||||
capability_channels.setdefault(capability, set()).add(channel)
|
||||
patterns = rule.get("patterns")
|
||||
if not isinstance(patterns, list) or not patterns:
|
||||
raise ProtocolError(f"{where}: patterns must be non-empty")
|
||||
for pattern_index, pattern in enumerate(patterns):
|
||||
pattern_where = f"{where}.patterns[{pattern_index}]"
|
||||
if not isinstance(pattern, list) or not pattern:
|
||||
raise ProtocolError(f"{pattern_where}: pattern must contain tokens")
|
||||
for token_index, token in enumerate(pattern):
|
||||
token_where = f"{pattern_where}[{token_index}]"
|
||||
if not isinstance(token, dict) or len(token) == 0:
|
||||
raise ProtocolError(f"{token_where}: token must be an object")
|
||||
kinds = {"literal", "enum", "integer"}.intersection(token)
|
||||
if len(kinds) != 1:
|
||||
raise ProtocolError(f"{token_where}: token needs one token kind")
|
||||
kind = next(iter(kinds))
|
||||
if kind == "literal" and not isinstance(token["literal"], str):
|
||||
raise ProtocolError(f"{token_where}: literal must be text")
|
||||
if kind == "enum":
|
||||
values = token.get("values")
|
||||
if not isinstance(values, list) or not values or not all(
|
||||
isinstance(item, str) for item in values
|
||||
):
|
||||
raise ProtocolError(f"{token_where}: enum values are invalid")
|
||||
if kind == "integer":
|
||||
minimum = token.get("minimum")
|
||||
maximum = token.get("maximum")
|
||||
if not isinstance(minimum, int) or not isinstance(maximum, int) or minimum > maximum:
|
||||
raise ProtocolError(f"{token_where}: integer range is invalid")
|
||||
|
||||
frames = rule.get("frames")
|
||||
if not isinstance(frames, list) or not frames:
|
||||
raise ProtocolError(f"{where}: frames must be non-empty")
|
||||
seen_keys: set[str] = set()
|
||||
for frame_index, frame in enumerate(frames):
|
||||
frame_where = f"{where}.frames[{frame_index}]"
|
||||
key = frame.get("key")
|
||||
if not isinstance(key, str) or not key or key in seen_keys:
|
||||
raise ProtocolError(f"{frame_where}: frame key is invalid or duplicate")
|
||||
seen_keys.add(key)
|
||||
_frame_id(frame.get("frame_id"), where=frame_where)
|
||||
frame_format = frame.get("format")
|
||||
if frame_format not in {"classic_can", "can_fd"}:
|
||||
raise ProtocolError(f"{frame_where}: unsupported frame format")
|
||||
if bool(frame.get("brs")) and frame_format != "can_fd":
|
||||
raise ProtocolError(f"{frame_where}: BRS requires CAN FD")
|
||||
dlc = frame.get("dlc")
|
||||
if not isinstance(dlc, int) or not 1 <= dlc <= 64:
|
||||
raise ProtocolError(f"{frame_where}: invalid DLC")
|
||||
_payload_bytes(frame.get("base_payload_hex", ""), where=frame_where, dlc=dlc)
|
||||
if not isinstance(frame.get("template_kind"), str) or not frame[
|
||||
"template_kind"
|
||||
]:
|
||||
raise ProtocolError(f"{frame_where}: template_kind is required")
|
||||
variants = frame.get("payload_variants", {})
|
||||
if not isinstance(variants, dict):
|
||||
raise ProtocolError(f"{frame_where}: payload_variants must be an object")
|
||||
for selector, payload in variants.items():
|
||||
if not isinstance(selector, str):
|
||||
raise ProtocolError(f"{frame_where}: payload selector must be text")
|
||||
_payload_bytes(payload, where=f"{frame_where}.{selector}", dlc=dlc)
|
||||
repeat = frame.get("repeat")
|
||||
if not isinstance(repeat, dict) or repeat.get("kind") not in {
|
||||
"fixed", "special", "duration_ceiling", "sequence"
|
||||
}:
|
||||
raise ProtocolError(f"{frame_where}: invalid repeat policy")
|
||||
count = repeat.get("count")
|
||||
period_ms = repeat.get("period_ms")
|
||||
if not isinstance(count, int) or count < 0 or not isinstance(period_ms, int) or period_ms < 0:
|
||||
raise ProtocolError(f"{frame_where}: invalid repeat count/period")
|
||||
if repeat["kind"] in {"fixed", "sequence"} and count < 1:
|
||||
raise ProtocolError(
|
||||
f"{frame_where}: {repeat['kind']} repeat count must be positive"
|
||||
)
|
||||
feedback_ids = frame.get("feedback_ids")
|
||||
if not isinstance(feedback_ids, list) or not feedback_ids:
|
||||
raise ProtocolError(f"{frame_where}: feedback_ids must be non-empty")
|
||||
for feedback_index, feedback_id in enumerate(feedback_ids):
|
||||
_frame_id(feedback_id, where=f"{frame_where}.feedback_ids[{feedback_index}]")
|
||||
|
||||
document["_capability_channels"] = {
|
||||
key: sorted(value) for key, value in sorted(capability_channels.items())
|
||||
}
|
||||
return document
|
||||
|
||||
|
||||
def _canonical_source(document: dict[str, Any]) -> bytes:
|
||||
source = {key: value for key, value in document.items() if not key.startswith("_")}
|
||||
return json.dumps(source, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def render_python(document: dict[str, Any]) -> str:
|
||||
digest = hashlib.sha256(_canonical_source(document)).hexdigest()
|
||||
source = {key: value for key, value in document.items() if not key.startswith("_")}
|
||||
literal = pprint.pformat(source, width=100, sort_dicts=False)
|
||||
return f'''# Generated by tools/generate_can_semantic_protocol.py; do not edit.
|
||||
"""Generated Python view of the shared CAN semantic protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
PROTOCOL_SOURCE_SHA256 = "{digest}"
|
||||
PROTOCOL_MANIFEST: dict[str, Any] = {literal}
|
||||
PROTOCOL_VERSION = str(PROTOCOL_MANIFEST["version"])
|
||||
RULES: tuple[dict[str, Any], ...] = tuple(PROTOCOL_MANIFEST["rules"])
|
||||
RULE_BY_ID = {{str(rule["id"]): rule for rule in RULES}}
|
||||
CAN0_SUPPORTED_CAPABILITIES = frozenset(
|
||||
capability
|
||||
for rule in RULES
|
||||
if int(rule["channel"]) == 0
|
||||
for capability in rule["capabilities"]
|
||||
)
|
||||
CAN1_SUPPORTED_CAPABILITIES = frozenset(
|
||||
capability
|
||||
for rule in RULES
|
||||
if int(rule["channel"]) == 1
|
||||
for capability in rule["capabilities"]
|
||||
)
|
||||
|
||||
|
||||
def rule(rule_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return RULE_BY_ID[rule_id]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"unknown generated CAN protocol rule {{rule_id}}") from exc
|
||||
|
||||
|
||||
def frame(rule_id: str, key: str = "command") -> dict[str, Any]:
|
||||
for item in rule(rule_id)["frames"]:
|
||||
if item["key"] == key:
|
||||
return item
|
||||
raise KeyError(f"rule {{rule_id}} has no frame named {{key}}")
|
||||
|
||||
|
||||
def command_contract(rule_id: str, key: str = "command") -> dict[str, Any]:
|
||||
metadata = frame(rule_id, key)
|
||||
return {{
|
||||
"source_version": PROTOCOL_VERSION,
|
||||
"rule_id": rule_id,
|
||||
"frame_key": key,
|
||||
"frame_id": metadata["frame_id"],
|
||||
"format": metadata["format"],
|
||||
"brs": bool(metadata["brs"]),
|
||||
"dlc": int(metadata["dlc"]),
|
||||
"repeat": dict(metadata["repeat"]),
|
||||
"feedback_ids": list(metadata["feedback_ids"]),
|
||||
"feedback_policy": str(metadata.get("feedback_policy", "")),
|
||||
}}
|
||||
|
||||
|
||||
def payload_variant(rule_id: str, selector: str, key: str = "command") -> bytes:
|
||||
item = frame(rule_id, key)
|
||||
try:
|
||||
value = item["payload_variants"][selector]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"rule {{rule_id}} frame {{key}} has no payload {{selector}}") from exc
|
||||
return bytes.fromhex(str(value))
|
||||
|
||||
|
||||
def rules_for_capability(capability: str, channel: int | None = None) -> tuple[dict[str, Any], ...]:
|
||||
return tuple(
|
||||
item
|
||||
for item in RULES
|
||||
if capability in item["capabilities"]
|
||||
and (channel is None or int(item["channel"]) == channel)
|
||||
)
|
||||
|
||||
|
||||
def _matches_token(word: str, token: dict[str, Any]) -> bool:
|
||||
if "literal" in token:
|
||||
return word == token["literal"]
|
||||
if "enum" in token:
|
||||
return word in token["values"]
|
||||
try:
|
||||
value = int(word, 10)
|
||||
except ValueError:
|
||||
return False
|
||||
return str(value) == word or (word.startswith("-") and str(value) == word)
|
||||
|
||||
|
||||
def _matches_integer(word: str, token: dict[str, Any]) -> bool:
|
||||
try:
|
||||
value = int(word, 10)
|
||||
except ValueError:
|
||||
return False
|
||||
return token["minimum"] <= value <= token["maximum"] and str(value) == word
|
||||
|
||||
|
||||
def match_semantic_command(words: str | Iterable[str], channel: int | None = None) -> dict[str, Any] | None:
|
||||
normalized = tuple(
|
||||
part.lower()
|
||||
for part in (words.split() if isinstance(words, str) else words)
|
||||
)
|
||||
for item in RULES:
|
||||
if channel is not None and int(item["channel"]) != channel:
|
||||
continue
|
||||
for pattern in item["patterns"]:
|
||||
if len(pattern) != len(normalized):
|
||||
continue
|
||||
if all(
|
||||
_matches_integer(word, token) if "integer" in token else _matches_token(word, token)
|
||||
for word, token in zip(normalized, pattern)
|
||||
):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def whitelist_help(channel: int) -> tuple[str, ...]:
|
||||
return tuple(str(item["help"]) for item in RULES if int(item["channel"]) == channel)
|
||||
'''
|
||||
|
||||
|
||||
def _cpp_escape(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def _cpp_condition(pattern: list[dict[str, Any]]) -> str:
|
||||
conditions = [f"words.size() == {len(pattern)}"]
|
||||
for index, token in enumerate(pattern):
|
||||
if "literal" in token:
|
||||
conditions.append(f'words[{index}] == "{_cpp_escape(token["literal"])}"')
|
||||
elif "enum" in token:
|
||||
values = ", ".join(f'"{_cpp_escape(value)}"' for value in token["values"])
|
||||
conditions.append(f"protocol_contains(words[{index}], {{{values}}})")
|
||||
else:
|
||||
conditions.append(
|
||||
f"protocol_integer_in_range(words[{index}], {token['minimum']}, {token['maximum']})"
|
||||
)
|
||||
return " &&\n ".join(conditions)
|
||||
|
||||
|
||||
def render_cpp(document: dict[str, Any]) -> str:
|
||||
digest = hashlib.sha256(_canonical_source(document)).hexdigest()
|
||||
rules = document["rules"]
|
||||
frames: list[tuple[str, dict[str, Any]]] = []
|
||||
payloads: list[tuple[str, str, str, str]] = []
|
||||
for rule in rules:
|
||||
for item in rule["frames"]:
|
||||
frames.append((rule["id"], item))
|
||||
for selector, payload in item.get("payload_variants", {}).items():
|
||||
payloads.append((rule["id"], item["key"], selector, payload))
|
||||
|
||||
frame_rows = []
|
||||
for rule_id, item in frames:
|
||||
repeat = item["repeat"]
|
||||
feedback_csv = ",".join(item["feedback_ids"])
|
||||
template_document = {
|
||||
key: item[key]
|
||||
for key in (
|
||||
"base_payload_hex",
|
||||
"fields",
|
||||
"payload_variants",
|
||||
"optional_when",
|
||||
)
|
||||
if key in item
|
||||
}
|
||||
template_json = json.dumps(
|
||||
template_document,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
frame_rows.append(
|
||||
" FrameMetadata{"
|
||||
f'"{_cpp_escape(rule_id)}", "{_cpp_escape(item["key"])}", '
|
||||
f'{int(item["frame_id"], 16)}, "{item["format"]}", '
|
||||
f'{str(bool(item["brs"])).lower()}, {item["dlc"]}, '
|
||||
f'"{_cpp_escape(item["template_kind"])}", '
|
||||
f'"{_cpp_escape(item["base_payload_hex"])}", '
|
||||
f'"{_cpp_escape(template_json)}", '
|
||||
f'"{_cpp_escape(repeat["kind"])}", {repeat["count"]}, {repeat["period_ms"]}, '
|
||||
f'"{_cpp_escape(feedback_csv)}", "{_cpp_escape(item.get("feedback_policy", ""))}"'
|
||||
"}"
|
||||
)
|
||||
rule_rows = []
|
||||
for rule in rules:
|
||||
capabilities = ",".join(rule["capabilities"])
|
||||
rule_rows.append(
|
||||
" RuleMetadata{"
|
||||
f'"{_cpp_escape(rule["id"])}", {rule["channel"]}, '
|
||||
f'"{_cpp_escape(rule["domain"])}", "{_cpp_escape(capabilities)}", '
|
||||
f'"{_cpp_escape(rule["help"])}", "{_cpp_escape(rule.get("special_handler", ""))}"'
|
||||
"}"
|
||||
)
|
||||
payload_rows = [
|
||||
" StaticPayload{"
|
||||
f'"{_cpp_escape(rule_id)}", "{_cpp_escape(key)}", "{_cpp_escape(selector)}", '
|
||||
f'"{_cpp_escape(payload)}"'
|
||||
"}"
|
||||
for rule_id, key, selector, payload in payloads
|
||||
]
|
||||
match_blocks = []
|
||||
for rule in rules:
|
||||
conditions = [f"({ _cpp_condition(pattern) })" for pattern in rule["patterns"]]
|
||||
joined = " ||\n ".join(conditions)
|
||||
match_blocks.append(
|
||||
f' if ((channel < 0 || channel == {rule["channel"]}) &&\n'
|
||||
f" ({joined}))\n"
|
||||
f' return find_rule("{_cpp_escape(rule["id"])}");'
|
||||
)
|
||||
|
||||
return f'''// Generated by tools/generate_can_semantic_protocol.py; do not edit.
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <charconv>
|
||||
#include <initializer_list>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace cognitive_agent_can_protocol {{
|
||||
|
||||
inline constexpr std::string_view kProtocolVersion = "{document["version"]}";
|
||||
inline constexpr std::string_view kProtocolSourceSha256 = "{digest}";
|
||||
|
||||
struct FrameMetadata {{
|
||||
std::string_view rule_id;
|
||||
std::string_view key;
|
||||
unsigned frame_id;
|
||||
std::string_view format;
|
||||
bool brs;
|
||||
unsigned dlc;
|
||||
std::string_view template_kind;
|
||||
std::string_view base_payload_hex;
|
||||
std::string_view template_json;
|
||||
std::string_view repeat_kind;
|
||||
unsigned repeat_count;
|
||||
unsigned period_ms;
|
||||
std::string_view feedback_ids_csv;
|
||||
std::string_view feedback_policy;
|
||||
}};
|
||||
|
||||
struct RuleMetadata {{
|
||||
std::string_view id;
|
||||
int channel;
|
||||
std::string_view domain;
|
||||
std::string_view capabilities_csv;
|
||||
std::string_view help;
|
||||
std::string_view special_handler;
|
||||
}};
|
||||
|
||||
struct StaticPayload {{
|
||||
std::string_view rule_id;
|
||||
std::string_view frame_key;
|
||||
std::string_view selector;
|
||||
std::string_view payload_hex;
|
||||
}};
|
||||
|
||||
inline constexpr std::array<FrameMetadata, {len(frame_rows)}> kFrames{{{{
|
||||
{',\n'.join(frame_rows)}
|
||||
}}}};
|
||||
|
||||
inline constexpr std::array<RuleMetadata, {len(rule_rows)}> kRules{{{{
|
||||
{',\n'.join(rule_rows)}
|
||||
}}}};
|
||||
|
||||
inline constexpr std::array<StaticPayload, {len(payload_rows)}> kStaticPayloads{{{{
|
||||
{',\n'.join(payload_rows)}
|
||||
}}}};
|
||||
|
||||
inline const RuleMetadata* find_rule(std::string_view id) {{
|
||||
for (const auto& item : kRules)
|
||||
if (item.id == id)
|
||||
return &item;
|
||||
return nullptr;
|
||||
}}
|
||||
|
||||
inline const FrameMetadata* find_frame(std::string_view rule_id,
|
||||
std::string_view key = "command") {{
|
||||
for (const auto& item : kFrames)
|
||||
if (item.rule_id == rule_id && item.key == key)
|
||||
return &item;
|
||||
return nullptr;
|
||||
}}
|
||||
|
||||
inline const StaticPayload* find_static_payload(std::string_view rule_id,
|
||||
std::string_view selector,
|
||||
std::string_view key = "command") {{
|
||||
for (const auto& item : kStaticPayloads)
|
||||
if (item.rule_id == rule_id && item.frame_key == key &&
|
||||
item.selector == selector)
|
||||
return &item;
|
||||
return nullptr;
|
||||
}}
|
||||
|
||||
inline bool protocol_contains(std::string_view value,
|
||||
std::initializer_list<std::string_view> allowed) {{
|
||||
for (const auto candidate : allowed)
|
||||
if (value == candidate)
|
||||
return true;
|
||||
return false;
|
||||
}}
|
||||
|
||||
inline bool protocol_integer_in_range(std::string_view text, int minimum,
|
||||
int maximum) {{
|
||||
int value = 0;
|
||||
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
return result.ec == std::errc{{}} && result.ptr == text.data() + text.size() &&
|
||||
std::to_string(value) == text && value >= minimum && value <= maximum;
|
||||
}}
|
||||
|
||||
inline const RuleMetadata* match_semantic_command(
|
||||
const std::vector<std::string>& words, int channel = -1) {{
|
||||
{chr(10).join(match_blocks)}
|
||||
return nullptr;
|
||||
}}
|
||||
|
||||
}} // namespace cognitive_agent_can_protocol
|
||||
'''
|
||||
|
||||
|
||||
def _write_or_check(path: Path, rendered: str, *, check: bool) -> bool:
|
||||
current = path.read_text(encoding="utf-8") if path.exists() else None
|
||||
if current == rendered:
|
||||
return True
|
||||
if check:
|
||||
print(f"generated file is stale: {path}", file=sys.stderr)
|
||||
return False
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(rendered, encoding="utf-8")
|
||||
print(f"generated {path.relative_to(ROOT)}")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
|
||||
parser.add_argument("--python-output", type=Path, default=DEFAULT_PYTHON)
|
||||
parser.add_argument("--cpp-output", type=Path, default=DEFAULT_CPP)
|
||||
parser.add_argument("--check", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
document = load_and_validate(args.source)
|
||||
except ProtocolError as exc:
|
||||
print(f"CAN protocol error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
ok_python = _write_or_check(
|
||||
args.python_output, render_python(document), check=args.check
|
||||
)
|
||||
ok_cpp = _write_or_check(args.cpp_output, render_cpp(document), check=args.check)
|
||||
return 0 if ok_python and ok_cpp else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,847 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the expanded natural-language to CAN command test catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from collections import Counter
|
||||
from itertools import combinations
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
JSON_PATH = ROOT / "tests/fixtures/natural_can_command_combination_suite.json"
|
||||
CSV_PATH = ROOT / "docs/testing/自然语言转CAN组合测试用例.csv"
|
||||
DOC_PATH = ROOT / "docs/testing/自然语言转CAN组合测试说明.md"
|
||||
|
||||
|
||||
def can0_operation(
|
||||
capability: str,
|
||||
parameters: dict[str, Any],
|
||||
target: str = "passenger",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"target": target,
|
||||
"capability": capability,
|
||||
"parameters": parameters,
|
||||
}
|
||||
|
||||
|
||||
def build_cases() -> list[dict[str, Any]]:
|
||||
cases: list[dict[str, Any]] = []
|
||||
|
||||
def add(
|
||||
case_id: str,
|
||||
utterance: str,
|
||||
*,
|
||||
domains: tuple[str, ...],
|
||||
channels: tuple[str, ...] = ("can1",),
|
||||
commands: tuple[str, ...] = (),
|
||||
can0_operations: tuple[dict[str, Any], ...] = (),
|
||||
kind: str = "atomic",
|
||||
status: str = "succeeded",
|
||||
sources: tuple[str, ...] = (),
|
||||
) -> dict[str, Any]:
|
||||
case = {
|
||||
"id": case_id,
|
||||
"kind": kind,
|
||||
"channels": list(channels),
|
||||
"domains": list(domains),
|
||||
"utterance": utterance,
|
||||
"expected_status": status,
|
||||
"expected_commands": list(commands),
|
||||
"expected_can0_operations": list(can0_operations),
|
||||
}
|
||||
if sources:
|
||||
case["source_case_ids"] = list(sources)
|
||||
cases.append(case)
|
||||
return case
|
||||
|
||||
add(
|
||||
"CAN1-DOOR-REAR-LOCK",
|
||||
"后排车门上锁",
|
||||
domains=("door",),
|
||||
commands=("door rear lock",),
|
||||
)
|
||||
add(
|
||||
"CAN1-DOOR-REAR-UNLOCK",
|
||||
"后排门锁解锁",
|
||||
domains=("door",),
|
||||
commands=("door rear unlock",),
|
||||
)
|
||||
|
||||
window_targets = {
|
||||
"ALL": ("全部车窗", "all"),
|
||||
"FRONT": ("前排车窗", "front"),
|
||||
"REAR": ("后排车窗", "rear"),
|
||||
"FRONT-LEFT": ("左前车窗", "front-left"),
|
||||
"FRONT-RIGHT": ("右前车窗", "front-right"),
|
||||
"REAR-LEFT": ("左后车窗", "rear-left"),
|
||||
"REAR-RIGHT": ("右后车窗", "rear-right"),
|
||||
}
|
||||
window_motions = {
|
||||
"OPEN": ("打开", "open"),
|
||||
"CLOSE": ("关闭", "close"),
|
||||
"STOP": ("停止", "stop"),
|
||||
}
|
||||
for target_id, (target_text, target_command) in window_targets.items():
|
||||
for motion_id, (verb, motion) in window_motions.items():
|
||||
add(
|
||||
f"CAN1-WINDOW-{target_id}-{motion_id}",
|
||||
f"{verb}{target_text}",
|
||||
domains=("window",),
|
||||
commands=(f"window {target_command} {motion}",),
|
||||
)
|
||||
for target_id, (target_text, target_command) in tuple(window_targets.items())[3:]:
|
||||
for position in (0, 25, 50, 100):
|
||||
add(
|
||||
f"CAN1-WINDOW-{target_id}-POSITION-{position}",
|
||||
f"把{target_text}开到{position}%",
|
||||
domains=("window",),
|
||||
commands=(f"window {target_command} position {position}",),
|
||||
)
|
||||
for alias_id, alias, target in (
|
||||
("DRIVER", "主驾", "front-left"),
|
||||
("PASSENGER", "副驾", "front-right"),
|
||||
):
|
||||
for motion_id, (verb, motion) in window_motions.items():
|
||||
add(
|
||||
f"CAN1-WINDOW-{alias_id}-ALIAS-{motion_id}",
|
||||
f"{verb}{alias}车窗",
|
||||
domains=("window",),
|
||||
commands=(f"window {target} {motion}",),
|
||||
)
|
||||
|
||||
add("CAN1-AC-ON", "打开空调", domains=("climate",), commands=("ac on",))
|
||||
add("CAN1-AC-OFF", "关闭空调", domains=("climate",), commands=("ac off",))
|
||||
for level in range(1, 8):
|
||||
add(
|
||||
f"CAN1-AC-FAN-{level}",
|
||||
f"空调风量调到{level}档",
|
||||
domains=("climate",),
|
||||
commands=(f"ac fan {level}",),
|
||||
)
|
||||
for alias_id, alias, level in (
|
||||
("MIN", "最小", 1),
|
||||
("LOWEST", "最低", 1),
|
||||
("LOW", "低档", 1),
|
||||
("MIDDLE", "中等", 4),
|
||||
("MEDIUM", "中档", 4),
|
||||
("HIGH", "高档", 7),
|
||||
("HIGHEST", "最高", 7),
|
||||
("MAX", "最大", 7),
|
||||
):
|
||||
add(
|
||||
f"CAN1-AC-FAN-ALIAS-{alias_id}",
|
||||
f"空调风量设置为{alias}",
|
||||
domains=("climate",),
|
||||
commands=(f"ac fan {level}",),
|
||||
)
|
||||
|
||||
outlet_modes = (
|
||||
("FACE", "吹脸", "face"),
|
||||
("FACE-FEET", "吹面和吹脚", "face-feet"),
|
||||
("FEET", "吹脚", "feet"),
|
||||
("FEET-DEFROST", "吹脚除霜", "feet-defrost"),
|
||||
("DEFROST", "除霜", "defrost"),
|
||||
("FACE-FEET-DEFROST", "吹脸、吹脚和除霜", "face-feet-defrost"),
|
||||
("FACE-DEFROST", "吹脸和除霜", "face-defrost"),
|
||||
)
|
||||
for mode_id, mode_text, mode in outlet_modes:
|
||||
add(
|
||||
f"CAN1-AC-MODE-{mode_id}",
|
||||
f"空调出风模式设置为{mode_text}",
|
||||
domains=("climate",),
|
||||
commands=(f"ac mode {mode}",),
|
||||
)
|
||||
for target_id, target_text, target in (
|
||||
("DRIVER", "主驾", "driver"),
|
||||
("PASSENGER", "副驾", "passenger"),
|
||||
("REAR", "后排", "rear"),
|
||||
):
|
||||
for temperature in (18, 24, 32):
|
||||
add(
|
||||
f"CAN1-AC-TEMP-{target_id}-{temperature}",
|
||||
f"{target_text}空调温度设置为{temperature}度",
|
||||
domains=("climate",),
|
||||
commands=(f"ac temp {target} {temperature}",),
|
||||
)
|
||||
for mode_id, mode_text, mode in (
|
||||
("MANUAL", "手动控制", "manual"),
|
||||
("AUTO", "自动控制", "auto"),
|
||||
):
|
||||
add(
|
||||
f"CAN1-AC-CONTROL-{mode_id}",
|
||||
f"空调设置为{mode_text}",
|
||||
domains=("climate",),
|
||||
commands=(f"ac control {mode}",),
|
||||
)
|
||||
for cycle_id, cycle_text, cycle in (
|
||||
("INTERNAL", "内循环", "internal"),
|
||||
("EXTERNAL", "外循环", "external"),
|
||||
):
|
||||
add(
|
||||
f"CAN1-AC-CYCLE-{cycle_id}",
|
||||
f"空调设置为{cycle_text}",
|
||||
domains=("climate",),
|
||||
commands=(f"ac cycle {cycle}",),
|
||||
)
|
||||
|
||||
outlet_targets = (
|
||||
("DRIVER-LEFT", "主驾左", "driver-left", (-25, -40)),
|
||||
("DRIVER-RIGHT", "主驾右", "driver-right", (30, 30)),
|
||||
("PASSENGER-LEFT", "副驾左", "passenger-left", (-10, -40)),
|
||||
("PASSENGER-RIGHT", "副驾右", "passenger-right", (5, 30)),
|
||||
)
|
||||
for target_id, target_text, target, calibrated in outlet_targets:
|
||||
for horizontal, vertical in ((0, 0), (25, 75), (100, 100)):
|
||||
add(
|
||||
f"CAN1-OUTLET-{target_id}-PERCENT-{horizontal}-{vertical}",
|
||||
f"{target_text}风口水平{horizontal}%上下{vertical}%",
|
||||
domains=("outlet",),
|
||||
commands=(
|
||||
f"outlet {target} percent {horizontal} {vertical}",
|
||||
),
|
||||
)
|
||||
add(
|
||||
f"CAN1-OUTLET-{target_id}-CALIBRATED",
|
||||
f"{target_text}风口标定坐标左右{calibrated[0]}上下{calibrated[1]}",
|
||||
domains=("outlet",),
|
||||
commands=(
|
||||
f"outlet {target} position {calibrated[0]} {calibrated[1]}",
|
||||
),
|
||||
)
|
||||
|
||||
for feature_id, feature_text, feature in (
|
||||
("VENT", "通风", "vent"),
|
||||
("HEAT", "加热", "heat"),
|
||||
):
|
||||
for level_id, level_text, level in (
|
||||
("OFF", "关闭", "off"),
|
||||
("LOW", "低档", "low"),
|
||||
("MIN", "最低", "low"),
|
||||
("HIGH", "高档", "high"),
|
||||
("MAX", "最大", "high"),
|
||||
):
|
||||
utterance = (
|
||||
f"关闭主驾座椅{feature_text}"
|
||||
if level == "off"
|
||||
else f"主驾座椅{feature_text}设置为{level_text}"
|
||||
)
|
||||
add(
|
||||
f"CAN1-SEAT-DRIVER-{feature_id}-{level_id}",
|
||||
utterance,
|
||||
domains=("seat",),
|
||||
commands=(f"seat driver {feature} {level}",),
|
||||
)
|
||||
add(
|
||||
f"CAN1-SEAT-DRIVER-{feature_id}-OPEN-DEFAULT",
|
||||
f"打开主驾座椅{feature_text}",
|
||||
domains=("seat",),
|
||||
commands=(f"seat driver {feature} low",),
|
||||
)
|
||||
|
||||
add(
|
||||
"CAN1-SEAT-DRIVER-MASSAGE-OPEN",
|
||||
"打开主驾座椅按摩",
|
||||
domains=("seat",),
|
||||
commands=("seat driver massage mode relax",),
|
||||
)
|
||||
add(
|
||||
"CAN1-SEAT-DRIVER-MASSAGE-OFF",
|
||||
"关闭主驾座椅按摩",
|
||||
domains=("seat",),
|
||||
commands=("seat driver massage off",),
|
||||
)
|
||||
for strength_id, strength_text, strength in (
|
||||
("WEAK", "最小", "weak"),
|
||||
("MEDIUM", "中等", "medium"),
|
||||
("STRONG", "最大", "strong"),
|
||||
("LOWEST", "最低", "weak"),
|
||||
("HIGHEST", "最高", "strong"),
|
||||
):
|
||||
add(
|
||||
f"CAN1-SEAT-DRIVER-MASSAGE-STRENGTH-{strength_id}",
|
||||
f"主驾座椅按摩强度设置为{strength_text}",
|
||||
domains=("seat",),
|
||||
commands=(f"seat driver massage strength {strength}",),
|
||||
)
|
||||
for mode_id, mode_text, mode in (
|
||||
("WAVE", "波浪", "wave"),
|
||||
("PULSE", "脉冲", "pulse"),
|
||||
("STRETCH", "伸展", "stretch"),
|
||||
("SLACK", "松弛", "slack"),
|
||||
("RELAX", "放松", "relax"),
|
||||
):
|
||||
add(
|
||||
f"CAN1-SEAT-DRIVER-MASSAGE-MODE-{mode_id}",
|
||||
f"主驾座椅按摩模式设置为{mode_text}",
|
||||
domains=("seat",),
|
||||
commands=(f"seat driver massage mode {mode}",),
|
||||
)
|
||||
|
||||
driver_motion_cases = (
|
||||
("HORIZONTAL-FORWARD", "主驾座椅向前移动500毫秒", "horizontal forward 500"),
|
||||
("HORIZONTAL-BACKWARD", "主驾座椅后退两秒", "horizontal backward 2000"),
|
||||
("HORIZONTAL-STOP", "停止主驾座椅前后移动", "horizontal stop"),
|
||||
("HEIGHT-UP", "主驾座椅往上移动300毫秒", "height up 300"),
|
||||
("HEIGHT-DOWN", "主驾座椅向下移动一秒", "height down 1000"),
|
||||
("HEIGHT-STOP", "停止主驾座椅高度调节", "height stop"),
|
||||
("BACKREST-FORWARD", "主驾座椅靠背调直500毫秒", "backrest forward 500"),
|
||||
("BACKREST-BACKWARD", "主驾座椅椅背放倒800毫秒", "backrest backward 800"),
|
||||
("BACKREST-STOP", "停止主驾座椅靠背动作", "backrest stop"),
|
||||
("BASIN-UP", "主驾座椅坐盆抬高300毫秒", "basin up 300"),
|
||||
("BASIN-DOWN", "主驾座椅座垫放低一秒", "basin down 1000"),
|
||||
("BASIN-STOP", "停止主驾座椅坐盆动作", "basin stop"),
|
||||
)
|
||||
for case_id, utterance, suffix in driver_motion_cases:
|
||||
add(
|
||||
f"CAN1-SEAT-DRIVER-{case_id}",
|
||||
utterance,
|
||||
domains=("seat",),
|
||||
commands=(f"seat driver {suffix}",),
|
||||
)
|
||||
add(
|
||||
"CAN1-SEAT-DRIVER-STOP-ALL",
|
||||
"停止主驾座椅移动",
|
||||
domains=("seat",),
|
||||
commands=(
|
||||
"seat driver horizontal stop",
|
||||
"seat driver height stop",
|
||||
"seat driver backrest stop",
|
||||
"seat driver basin stop",
|
||||
),
|
||||
)
|
||||
for slot in (1, 2, 3):
|
||||
add(
|
||||
f"CAN1-SEAT-DRIVER-MEMORY-{slot}",
|
||||
f"调用主驾座椅记忆位置{slot}",
|
||||
domains=("seat",),
|
||||
commands=(f"seat driver position {slot}",),
|
||||
)
|
||||
|
||||
for state_id, state_text, state in (
|
||||
("ON", "打开", "on"),
|
||||
("OFF", "关闭", "off"),
|
||||
):
|
||||
add(
|
||||
f"CAN1-LIGHT-INDOOR-{state_id}",
|
||||
f"{state_text}室内灯",
|
||||
domains=("interior_light",),
|
||||
commands=(f"light indoor {state}",),
|
||||
)
|
||||
add(
|
||||
f"CAN1-LIGHT-READING-ALL-{state_id}",
|
||||
f"{state_text}全部阅读灯",
|
||||
domains=("interior_light",),
|
||||
commands=(f"light reading all {state}",),
|
||||
)
|
||||
add(
|
||||
f"CAN1-LIGHT-AMBIENT-{state_id}",
|
||||
f"{state_text}氛围灯",
|
||||
domains=("ambient_light",),
|
||||
commands=(f"light ambient {state}",),
|
||||
)
|
||||
for color in (1, 32, 70, 127):
|
||||
add(
|
||||
f"CAN1-LIGHT-AMBIENT-COLOR-{color}",
|
||||
f"氛围灯颜色编号设置为{color}",
|
||||
domains=("ambient_light",),
|
||||
commands=(f"light ambient color {color}",),
|
||||
)
|
||||
for brightness in range(1, 6):
|
||||
add(
|
||||
f"CAN1-LIGHT-AMBIENT-BRIGHTNESS-{brightness}",
|
||||
f"氛围灯亮度设置为{brightness}档",
|
||||
domains=("ambient_light",),
|
||||
commands=(f"light ambient brightness {brightness}",),
|
||||
)
|
||||
for alias_id, alias, brightness in (
|
||||
("MIN", "最低", 1),
|
||||
("MIDDLE", "中等", 3),
|
||||
("MAX", "最高", 5),
|
||||
):
|
||||
add(
|
||||
f"CAN1-LIGHT-AMBIENT-BRIGHTNESS-ALIAS-{alias_id}",
|
||||
f"氛围灯亮度设置为{alias}",
|
||||
domains=("ambient_light",),
|
||||
commands=(f"light ambient brightness {brightness}",),
|
||||
)
|
||||
|
||||
for motion_id, motion_text, motion in (
|
||||
("OPEN", "打开", "open"),
|
||||
("CLOSE", "关闭", "close"),
|
||||
("HALF", "半开", "half"),
|
||||
("STOP", "停止", "stop"),
|
||||
):
|
||||
utterance = f"遮阳帘{motion_text}" if motion == "half" else f"{motion_text}遮阳帘"
|
||||
add(
|
||||
f"CAN1-SHADE-{motion_id}",
|
||||
utterance,
|
||||
domains=("roof",),
|
||||
commands=(f"shade {motion}",),
|
||||
)
|
||||
for position in (0, 20, 30, 50, 100):
|
||||
add(
|
||||
f"CAN1-SHADE-POSITION-{position}",
|
||||
f"遮阳帘打开到{position}%",
|
||||
domains=("roof",),
|
||||
commands=(f"shade position {position}",),
|
||||
)
|
||||
|
||||
can0_atoms = (
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-HEAT-1",
|
||||
"副驾驶座椅加热开到1档",
|
||||
can0_operation("seat.set_heating", {"state": "on", "level": 1}),
|
||||
),
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-HEAT-2",
|
||||
"副驾驶座椅加热开到2档",
|
||||
can0_operation("seat.set_heating", {"state": "on", "level": 2}),
|
||||
),
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-HEAT-3",
|
||||
"副驾驶座椅加热开到3档",
|
||||
can0_operation("seat.set_heating", {"state": "on", "level": 3}),
|
||||
),
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-HEAT-OFF",
|
||||
"关闭副驾驶座椅加热",
|
||||
can0_operation("seat.set_heating", {"state": "off"}),
|
||||
),
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-VENT-1",
|
||||
"副驾驶座椅通风开到1档",
|
||||
can0_operation("seat.set_ventilation", {"state": "on", "level": 1}),
|
||||
),
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-VENT-2",
|
||||
"副驾驶座椅通风开到2档",
|
||||
can0_operation("seat.set_ventilation", {"state": "on", "level": 2}),
|
||||
),
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-VENT-3",
|
||||
"副驾驶座椅通风开到3档",
|
||||
can0_operation("seat.set_ventilation", {"state": "on", "level": 3}),
|
||||
),
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-VENT-OFF",
|
||||
"关闭副驾驶座椅通风",
|
||||
can0_operation("seat.set_ventilation", {"state": "off"}),
|
||||
),
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-HORIZONTAL-30",
|
||||
"把副驾驶座椅水平位置调到30%",
|
||||
can0_operation("seat.set_longitudinal_position", {"position_pct": 30}),
|
||||
),
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-BASIN-40",
|
||||
"把副驾驶坐盆倾角调到40%",
|
||||
can0_operation("seat.set_cushion_tilt", {"position_pct": 40}),
|
||||
),
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-BACKREST-120",
|
||||
"把副驾驶靠背调到120度",
|
||||
can0_operation(
|
||||
"seat.set_backrest_recline",
|
||||
{"position_pct": 37, "angle_deg": 120},
|
||||
),
|
||||
),
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-LEGREST-125",
|
||||
"把副驾驶腿托抬到125度",
|
||||
can0_operation(
|
||||
"seat.set_legrest_height",
|
||||
{"position_pct": 42, "angle_deg": 125},
|
||||
),
|
||||
),
|
||||
)
|
||||
for case_id, utterance, operation in can0_atoms:
|
||||
add(
|
||||
case_id,
|
||||
utterance,
|
||||
domains=("seat",),
|
||||
channels=("can0",),
|
||||
can0_operations=(operation,),
|
||||
)
|
||||
|
||||
atom_by_id = {case["id"]: case for case in cases}
|
||||
|
||||
def combine(
|
||||
case_id: str,
|
||||
source_ids: tuple[str, ...],
|
||||
*,
|
||||
separator: str = ",",
|
||||
kind: str,
|
||||
) -> None:
|
||||
source_cases = [atom_by_id[source_id] for source_id in source_ids]
|
||||
channels = tuple(
|
||||
channel
|
||||
for channel in ("can0", "can1")
|
||||
if any(channel in item["channels"] for item in source_cases)
|
||||
)
|
||||
domains = tuple(
|
||||
dict.fromkeys(
|
||||
domain for item in source_cases for domain in item["domains"]
|
||||
)
|
||||
)
|
||||
add(
|
||||
case_id,
|
||||
separator.join(item["utterance"] for item in source_cases),
|
||||
domains=domains,
|
||||
channels=channels,
|
||||
commands=tuple(
|
||||
command
|
||||
for item in source_cases
|
||||
for command in item["expected_commands"]
|
||||
),
|
||||
can0_operations=tuple(
|
||||
operation
|
||||
for item in source_cases
|
||||
for operation in item["expected_can0_operations"]
|
||||
),
|
||||
kind=kind,
|
||||
sources=source_ids,
|
||||
)
|
||||
|
||||
pair_pool = (
|
||||
"CAN1-DOOR-REAR-LOCK",
|
||||
"CAN1-WINDOW-FRONT-LEFT-OPEN",
|
||||
"CAN1-WINDOW-REAR-RIGHT-POSITION-25",
|
||||
"CAN1-AC-ON",
|
||||
"CAN1-AC-FAN-2",
|
||||
"CAN1-AC-MODE-FACE-FEET",
|
||||
"CAN1-AC-TEMP-DRIVER-24",
|
||||
"CAN1-AC-TEMP-PASSENGER-24",
|
||||
"CAN1-AC-TEMP-REAR-24",
|
||||
"CAN1-AC-CONTROL-AUTO",
|
||||
"CAN1-AC-CYCLE-INTERNAL",
|
||||
"CAN1-OUTLET-DRIVER-LEFT-PERCENT-25-75",
|
||||
"CAN1-OUTLET-PASSENGER-RIGHT-PERCENT-25-75",
|
||||
"CAN1-SEAT-DRIVER-VENT-HIGH",
|
||||
"CAN1-SEAT-DRIVER-HEAT-LOW",
|
||||
"CAN1-SEAT-DRIVER-MASSAGE-MODE-RELAX",
|
||||
"CAN1-SEAT-DRIVER-MASSAGE-STRENGTH-MEDIUM",
|
||||
"CAN1-SEAT-DRIVER-HORIZONTAL-FORWARD",
|
||||
"CAN1-SEAT-DRIVER-HEIGHT-UP",
|
||||
"CAN1-LIGHT-INDOOR-ON",
|
||||
"CAN1-LIGHT-READING-ALL-ON",
|
||||
"CAN1-LIGHT-AMBIENT-ON",
|
||||
"CAN1-LIGHT-AMBIENT-COLOR-70",
|
||||
"CAN1-LIGHT-AMBIENT-BRIGHTNESS-3",
|
||||
"CAN1-SHADE-POSITION-30",
|
||||
)
|
||||
for index, source_pair in enumerate(combinations(pair_pool, 2), start=1):
|
||||
combine(
|
||||
f"COMBO-PAIR-COMMA-{index:03d}",
|
||||
source_pair,
|
||||
kind="pairwise",
|
||||
)
|
||||
combine(
|
||||
f"COMBO-PAIR-SEMICOLON-{index:03d}",
|
||||
source_pair,
|
||||
separator=";",
|
||||
kind="pairwise_separator_variant",
|
||||
)
|
||||
|
||||
multi_anchors = (
|
||||
"CAN1-DOOR-REAR-LOCK",
|
||||
"CAN1-WINDOW-FRONT-LEFT-OPEN",
|
||||
"CAN1-AC-FAN-2",
|
||||
"CAN1-OUTLET-DRIVER-LEFT-PERCENT-25-75",
|
||||
"CAN1-SEAT-DRIVER-VENT-HIGH",
|
||||
"CAN1-LIGHT-INDOOR-ON",
|
||||
"CAN1-LIGHT-AMBIENT-COLOR-70",
|
||||
"CAN1-SHADE-POSITION-30",
|
||||
"CAN0-SEAT-PASSENGER-HEAT-2",
|
||||
)
|
||||
for index, source_ids in enumerate(combinations(multi_anchors, 3), start=1):
|
||||
combine(
|
||||
f"COMBO-TRIPLE-{index:03d}",
|
||||
source_ids,
|
||||
kind="three_command",
|
||||
)
|
||||
for index, source_ids in enumerate(combinations(multi_anchors, 4), start=1):
|
||||
combine(
|
||||
f"COMBO-QUADRUPLE-{index:03d}",
|
||||
source_ids,
|
||||
kind="four_command",
|
||||
)
|
||||
|
||||
cross_can1 = pair_pool[:15]
|
||||
cross_can0 = tuple(item[0] for item in can0_atoms[0:8])
|
||||
for index, (can1_id, can0_id) in enumerate(
|
||||
((left, right) for left in cross_can1 for right in cross_can0),
|
||||
start=1,
|
||||
):
|
||||
combine(
|
||||
f"COMBO-CROSS-CHANNEL-{index:03d}",
|
||||
(can1_id, can0_id),
|
||||
kind="cross_can0_can1",
|
||||
)
|
||||
|
||||
scenes = (
|
||||
(
|
||||
"MOVIE",
|
||||
(
|
||||
"CAN1-AC-ON",
|
||||
"CAN1-AC-TEMP-DRIVER-24",
|
||||
"CAN1-AC-FAN-2",
|
||||
"CAN1-SHADE-POSITION-100",
|
||||
"CAN1-LIGHT-AMBIENT-COLOR-70",
|
||||
"CAN1-LIGHT-AMBIENT-BRIGHTNESS-3",
|
||||
),
|
||||
),
|
||||
(
|
||||
"DRIVER-COMFORT",
|
||||
(
|
||||
"CAN1-SEAT-DRIVER-VENT-LOW",
|
||||
"CAN1-SEAT-DRIVER-MASSAGE-MODE-RELAX",
|
||||
"CAN1-SEAT-DRIVER-MASSAGE-STRENGTH-MEDIUM",
|
||||
"CAN1-AC-TEMP-DRIVER-24",
|
||||
"CAN1-AC-FAN-2",
|
||||
),
|
||||
),
|
||||
(
|
||||
"PASSENGER-COMFORT",
|
||||
(
|
||||
"CAN0-SEAT-PASSENGER-HEAT-2",
|
||||
"CAN0-SEAT-PASSENGER-HORIZONTAL-30",
|
||||
"CAN0-SEAT-PASSENGER-BASIN-40",
|
||||
"CAN0-SEAT-PASSENGER-BACKREST-120",
|
||||
"CAN0-SEAT-PASSENGER-LEGREST-125",
|
||||
),
|
||||
),
|
||||
(
|
||||
"CABIN-BRIGHT",
|
||||
(
|
||||
"CAN1-LIGHT-INDOOR-ON",
|
||||
"CAN1-LIGHT-READING-ALL-ON",
|
||||
"CAN1-LIGHT-AMBIENT-ON",
|
||||
"CAN1-LIGHT-AMBIENT-COLOR-70",
|
||||
"CAN1-LIGHT-AMBIENT-BRIGHTNESS-5",
|
||||
),
|
||||
),
|
||||
(
|
||||
"VENTILATION",
|
||||
(
|
||||
"CAN1-AC-ON",
|
||||
"CAN1-AC-FAN-7",
|
||||
"CAN1-AC-MODE-FACE-FEET",
|
||||
"CAN1-AC-CYCLE-EXTERNAL",
|
||||
"CAN1-OUTLET-DRIVER-LEFT-PERCENT-25-75",
|
||||
"CAN1-OUTLET-PASSENGER-RIGHT-PERCENT-25-75",
|
||||
),
|
||||
),
|
||||
(
|
||||
"PARK-CLOSE",
|
||||
(
|
||||
"CAN1-WINDOW-ALL-CLOSE",
|
||||
"CAN1-SHADE-CLOSE",
|
||||
"CAN1-DOOR-REAR-LOCK",
|
||||
"CAN1-LIGHT-INDOOR-OFF",
|
||||
"CAN1-LIGHT-AMBIENT-OFF",
|
||||
"CAN1-AC-OFF",
|
||||
),
|
||||
),
|
||||
(
|
||||
"DUAL-SEAT",
|
||||
(
|
||||
"CAN1-SEAT-DRIVER-VENT-HIGH",
|
||||
"CAN1-SEAT-DRIVER-MASSAGE-MODE-WAVE",
|
||||
"CAN0-SEAT-PASSENGER-VENT-2",
|
||||
"CAN0-SEAT-PASSENGER-BACKREST-120",
|
||||
"CAN1-AC-TEMP-DRIVER-24",
|
||||
"CAN1-AC-TEMP-PASSENGER-24",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ALL-ZONES-CLIMATE",
|
||||
(
|
||||
"CAN1-AC-ON",
|
||||
"CAN1-AC-TEMP-DRIVER-24",
|
||||
"CAN1-AC-TEMP-PASSENGER-24",
|
||||
"CAN1-AC-TEMP-REAR-24",
|
||||
"CAN1-AC-FAN-4",
|
||||
"CAN1-AC-CONTROL-AUTO",
|
||||
"CAN1-AC-CYCLE-INTERNAL",
|
||||
),
|
||||
),
|
||||
(
|
||||
"WINDOWS-SHADE",
|
||||
(
|
||||
"CAN1-WINDOW-FRONT-LEFT-POSITION-25",
|
||||
"CAN1-WINDOW-FRONT-RIGHT-POSITION-50",
|
||||
"CAN1-WINDOW-REAR-LEFT-POSITION-100",
|
||||
"CAN1-WINDOW-REAR-RIGHT-POSITION-0",
|
||||
"CAN1-SHADE-POSITION-30",
|
||||
),
|
||||
),
|
||||
(
|
||||
"DRIVER-POSTURE",
|
||||
(
|
||||
"CAN1-SEAT-DRIVER-HORIZONTAL-FORWARD",
|
||||
"CAN1-SEAT-DRIVER-HEIGHT-UP",
|
||||
"CAN1-SEAT-DRIVER-BACKREST-BACKWARD",
|
||||
"CAN1-SEAT-DRIVER-BASIN-DOWN",
|
||||
),
|
||||
),
|
||||
)
|
||||
for scene_id, source_ids in scenes:
|
||||
combine(
|
||||
f"COMBO-SCENE-{scene_id}",
|
||||
source_ids,
|
||||
kind="multi_command_scene",
|
||||
)
|
||||
|
||||
safety_cases = (
|
||||
("DRIVER-VENT-LEVEL-4", "主驾座椅通风4档"),
|
||||
("DRIVER-VENT-MIDDLE", "主驾座椅通风中档"),
|
||||
("DRIVER-ABSOLUTE-BACKREST", "把驾驶位靠背调到50%"),
|
||||
("DRIVER-UNVERIFIED-MODE", "主驾进入零重力模式"),
|
||||
("FRONT-ROW-SEAT", "打开前排座椅加热"),
|
||||
("PASSENGER-HEAT-LEVEL-4", "副驾座椅加热开到4档"),
|
||||
("PASSENGER-VENT-MISSING-STATE", "副驾座椅通风"),
|
||||
("PASSENGER-CONTRADICTORY", "打开副驾加热然后关闭副驾加热"),
|
||||
("PASSENGER-MASSAGE", "把副驾座椅按摩打开"),
|
||||
("MULTIPLE-SEAT-MODES", "进入办公模式和零重力模式"),
|
||||
("PASSENGER-LEGREST-EXTEND", "把副驾腿托伸长到50%"),
|
||||
("WRONG-AXIS-UNIT", "坐垫向前移动10度"),
|
||||
("MISSING-SEAT-AXIS", "向前移动10度"),
|
||||
("REAR-SEAT", "打开后排座椅加热"),
|
||||
("ALL-SEATS", "打开所有座椅加热"),
|
||||
("OUTLET-DISABLE-WITHOUT-MODE", "关闭吹面出风口"),
|
||||
)
|
||||
for case_id, utterance in safety_cases:
|
||||
add(
|
||||
f"SAFETY-{case_id}",
|
||||
utterance,
|
||||
domains=("safety",),
|
||||
channels=(),
|
||||
kind="safety_rejection",
|
||||
status="needs_confirmation",
|
||||
)
|
||||
return cases
|
||||
|
||||
|
||||
def write_outputs(cases: list[dict[str, Any]]) -> None:
|
||||
kind_counts = Counter(case["kind"] for case in cases)
|
||||
channel_counts = Counter(
|
||||
"+".join(case["channels"]) or "none" for case in cases
|
||||
)
|
||||
payload = {
|
||||
"schema": "natural-can-command-combination-suite/v1",
|
||||
"generated_on": "2026-08-24",
|
||||
"description": "自然语言控制指令到CAN0/CAN1语义控制指令的原子、组合和安全测试目录。",
|
||||
"summary": {
|
||||
"total_cases": len(cases),
|
||||
"by_kind": dict(sorted(kind_counts.items())),
|
||||
"by_channel": dict(sorted(channel_counts.items())),
|
||||
},
|
||||
"cases": cases,
|
||||
}
|
||||
JSON_PATH.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with CSV_PATH.open("w", encoding="utf-8-sig", newline="") as handle:
|
||||
writer = csv.DictWriter(
|
||||
handle,
|
||||
fieldnames=(
|
||||
"id",
|
||||
"kind",
|
||||
"channels",
|
||||
"domains",
|
||||
"utterance",
|
||||
"expected_status",
|
||||
"expected_commands",
|
||||
"expected_can0_operations",
|
||||
"source_case_ids",
|
||||
),
|
||||
)
|
||||
writer.writeheader()
|
||||
for case in cases:
|
||||
writer.writerow(
|
||||
{
|
||||
**case,
|
||||
"channels": "/".join(case["channels"]),
|
||||
"domains": "/".join(case["domains"]),
|
||||
"expected_commands": " | ".join(case["expected_commands"]),
|
||||
"expected_can0_operations": json.dumps(
|
||||
case["expected_can0_operations"], ensure_ascii=False
|
||||
),
|
||||
"source_case_ids": " | ".join(
|
||||
case.get("source_case_ids", [])
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
kind_lines = "\n".join(
|
||||
f"| `{kind}` | {count} |" for kind, count in sorted(kind_counts.items())
|
||||
)
|
||||
channel_lines = "\n".join(
|
||||
f"| `{channel}` | {count} |"
|
||||
for channel, count in sorted(channel_counts.items())
|
||||
)
|
||||
DOC_PATH.write_text(
|
||||
f"""# 自然语言转 CAN 控制指令组合测试说明
|
||||
|
||||
- 生成日期:2026-08-24
|
||||
- 总用例数:**{len(cases)}**
|
||||
- 执行方式:本地规则、`dry_run`,不发送真实 CAN 帧
|
||||
- 完整逐条清单:`docs/testing/自然语言转CAN组合测试用例.csv`
|
||||
- 机器可执行数据:`tests/fixtures/natural_can_command_combination_suite.json`
|
||||
|
||||
## 覆盖数量
|
||||
|
||||
| 用例类型 | 数量 |
|
||||
| --- | ---: |
|
||||
{kind_lines}
|
||||
|
||||
| 通道 | 数量 |
|
||||
| --- | ---: |
|
||||
{channel_lines}
|
||||
|
||||
## 组合策略
|
||||
|
||||
1. 原子用例覆盖 CAN1 门锁、车窗、空调、风口、主驾座椅、灯光、遮阳帘,以及 CAN0 副驾座椅。
|
||||
2. 两两组合从 25 个互不冲突的控制槽中取组合,同时覆盖中文逗号和分号分隔形式。
|
||||
3. 三指令和四指令组合覆盖 CAN0/CAN1、座椅、空调、灯光、车窗、门锁、风口与遮阳帘的交叉。
|
||||
4. 跨通道组合明确检查副驾座椅留在 CAN0、主驾和车身控制留在 CAN1。
|
||||
5. 多指令场景覆盖观影、双座椅、全温区空调、泊车关闭、车窗与遮阳帘等常用场景。
|
||||
6. 安全用例要求白名单外目标、档位、能力和矛盾指令不生成 CAN 控制流。
|
||||
|
||||
## 执行
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src:domains/seat/src .venv/bin/python -m pytest -q tests/integration/test_natural_can_command_combinations.py
|
||||
```
|
||||
|
||||
重新生成清单:
|
||||
|
||||
```bash
|
||||
.venv/bin/python tools/generate_natural_can_command_suite.py
|
||||
```
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cases = build_cases()
|
||||
ids = [case["id"] for case in cases]
|
||||
if len(ids) != len(set(ids)):
|
||||
duplicates = [item for item, count in Counter(ids).items() if count > 1]
|
||||
raise ValueError(f"duplicate case ids: {duplicates}")
|
||||
write_outputs(cases)
|
||||
print(f"generated {len(cases)} cases")
|
||||
print(JSON_PATH)
|
||||
print(CSV_PATH)
|
||||
print(DOC_PATH)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,6 +8,16 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from generate_can_semantic_protocol import (
|
||||
DEFAULT_CPP as CAN_PROTOCOL_CPP,
|
||||
DEFAULT_PYTHON as CAN_PROTOCOL_PYTHON,
|
||||
DEFAULT_SOURCE as CAN_PROTOCOL_SOURCE,
|
||||
ProtocolError as CanProtocolError,
|
||||
load_and_validate as load_can_protocol,
|
||||
render_cpp as render_can_protocol_cpp,
|
||||
render_python as render_can_protocol_python,
|
||||
)
|
||||
|
||||
try:
|
||||
import yaml
|
||||
from jsonschema import Draft202012Validator, FormatChecker, RefResolver
|
||||
@@ -248,6 +258,27 @@ def validate_platform_resources(errors: list[str], warnings: list[str]) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def validate_generated_can_protocol(errors: list[str]) -> int:
|
||||
try:
|
||||
document = load_can_protocol(CAN_PROTOCOL_SOURCE)
|
||||
expected = {
|
||||
CAN_PROTOCOL_PYTHON: render_can_protocol_python(document),
|
||||
CAN_PROTOCOL_CPP: render_can_protocol_cpp(document),
|
||||
}
|
||||
except CanProtocolError as exc:
|
||||
errors.append(f"{relative(CAN_PROTOCOL_SOURCE)}: {exc}")
|
||||
return 0
|
||||
for path, content in expected.items():
|
||||
if not path.is_file():
|
||||
errors.append(f"{relative(path)}: generated CAN protocol view is missing")
|
||||
elif path.read_text(encoding="utf-8") != content:
|
||||
errors.append(
|
||||
f"{relative(path)}: stale generated CAN protocol view; "
|
||||
"run make can-protocol-generate"
|
||||
)
|
||||
return len(document["rules"])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
@@ -255,6 +286,7 @@ def main() -> int:
|
||||
document_count = validate_documents(schemas, schema_store, errors)
|
||||
domain_count = validate_domains(errors)
|
||||
platform_count = validate_platform_resources(errors, warnings)
|
||||
protocol_rule_count = validate_generated_can_protocol(errors)
|
||||
|
||||
for warning in warnings:
|
||||
print(f"WARNING: {warning}")
|
||||
@@ -267,7 +299,8 @@ def main() -> int:
|
||||
print(
|
||||
"Repository validation passed: "
|
||||
f"{len(schemas)} schemas, {document_count} documents, "
|
||||
f"{domain_count} domains, {platform_count} platform(s)."
|
||||
f"{domain_count} domains, {platform_count} platform(s), "
|
||||
f"{protocol_rule_count} generated CAN protocol rule(s)."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user