优化无线/有线CAN,以及控制指令解析

This commit is contained in:
minghanchen
2026-08-26 20:11:09 +08:00
parent 9ac8fdface
commit 4a0570706f
49 changed files with 8409 additions and 1289 deletions
+1
View File
@@ -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/
+21 -3
View File
@@ -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
+10 -2
View File
@@ -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`。
+125 -42
View File
@@ -2478,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,
@@ -4323,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
@@ -4343,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"} 当前未配置(仍可手动选择)`;
@@ -4362,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`;
}
}
@@ -4445,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;
@@ -4476,6 +4509,9 @@ function selectExecutionMode(mode, { remember = true } = {}) {
? "真实执行"
: simulatedHardware ? "运行故障仿真" : "生成并执行";
}
if (remember && mode === "hardware" && !hardwareExecutionReady) {
showToast(`已切换到真实台架;当前不可执行:${hardwareExecutionIssue()}`);
}
updateSeatFeedbackModeLabel();
refreshIcons();
return true;
@@ -5132,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 };
@@ -5798,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
@@ -5822,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
@@ -5839,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 });
@@ -6725,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": "设置壁灯",
@@ -6991,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");
@@ -7011,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";
@@ -7024,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 mqttSubmittedOperations = operationResults.filter(
(item) => ["queued", "published"].includes(item.telemetry?.delivery_status),
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"
&& mqttSubmittedOperations.length > physicallyConfirmedOperations.length;
const succeededDomains = [...new Set(succeededOperations.map((item) => item.domain))];
const failedDomains = [...new Set(failedOperations.map((item) => item.domain))];
let executionDetail = modeSave
@@ -7041,8 +7116,10 @@ function renderExecution(result) {
: simulatedHil
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
: physical
? result.control_transport === "wireless_mqtt"
? `${executedDomains.join(" / ")} 已向 MQTT 提交 ${mqttSubmittedOperations.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} 个步骤`
@@ -7056,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
@@ -7082,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
? `${mqttSubmittedOperations.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;
@@ -7250,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) => ["queued", "published"].includes(item.telemetry?.delivery_status)
&& 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";
@@ -7289,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";
@@ -7302,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 || []);
@@ -7353,6 +7431,8 @@ async function postControlPhase(path, body, executionMode, onEvent = null) {
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;
}
@@ -7572,6 +7652,7 @@ async function submitRequest(event) {
setModeExecutionState(submittedModeProfileId, completed ? "succeeded" : "failed");
}
const simulated = payload.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
const submittedUnverified = executionVerificationStatus(payload) === "submitted_unverified";
showToast(supplementWarning || (completed
? (payload.mode_profile
? `${payload.mode_profile.name}已创建并添加到功能模式`
@@ -7579,6 +7660,8 @@ async function submitRequest(event) {
? "已执行可用设备;部分设备未完成,详见执行结果"
: simulated
? "模拟台架执行完成"
: submittedUnverified
? "CAN 控制指令已提交;等待车辆实时状态确认"
: payload.execution_report?.spec?.mode === "hil"
? "真实台架执行与反馈确认已完成"
: "可执行动作的 dry-run 已完成")
+117 -25
View File
@@ -22,6 +22,31 @@
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);
@@ -71,10 +96,13 @@
...(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);
@@ -88,7 +116,6 @@
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 = {
@@ -104,7 +131,9 @@
? `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)) {
@@ -124,14 +153,12 @@
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 ${dynamic.name}`,
`seat passenger dynamic ${dynamic}`,
"CAN0",
otaCan0Frame(0x66, dynamic.selector, 300),
nativeCan0Frame(0x0E, 0, 0, mode, 0),
);
}
}
@@ -140,7 +167,7 @@
"副驾座椅动态模式",
"seat passenger dynamic stop",
"CAN0",
otaCan0Frame(0x63, 0xFF, 0),
nativeCan0Frame(0x0F, 0, 0, 0, 0),
);
}
}
@@ -198,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);
@@ -330,7 +364,7 @@
${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>`;
@@ -359,6 +393,10 @@
<code>${escapeHtml(item.command)}</code>
</li>`).join("")}</ol>`;
validatedCommandsVisible = true;
validatedCommandContext = {
requestId: "",
utterance: items.map((item) => item.command).join(";"),
};
requestAnimationFrame(syncSidebarLogBaseline);
}
@@ -1184,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"],
@@ -1192,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;
@@ -1207,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);
}
}
}
@@ -1245,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;
@@ -1487,26 +1549,44 @@
});
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
$("#requestForm")?.addEventListener("submit", () => {
validatedCommandsVisible = false;
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 || {};
validatedCommandsVisible = parsedCanCommands(result).length > 0;
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) => {
@@ -1514,7 +1594,19 @@
renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" });
}
if (event.detail?.error) {
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) => {
+3 -3
View File
@@ -17,10 +17,10 @@
<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-103" 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-43" 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>
+3 -3
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v204";
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-103",
"/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-41",
"/console-v2.js?v=sync-layout-43",
"/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",
+433
View File
@@ -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]
+34 -1
View File
@@ -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 @@
}
}
}
+15 -11
View File
@@ -13,19 +13,19 @@ CAN0 与 CAN1 必须分开解释。本文中的 `seat passenger ...` 只生成
| 类别 | 规范指令 | 参数约束 | 当前执行逻辑 |
| --- | --- | --- | --- |
| 水平绝对位置 | `seat passenger horizontal position <0-100>` | 整数百分比 | 有线:`0x300`,一般位置使用 Opcode `0x01`;显式 0% 使用安全归零 Opcode `0x0B`。无线:转换为 seat-ota `0x6A0` MOVE,并以新鲜 `0x302` 确认位置 |
| 水平绝对位置 | `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`,位置未标定且移动量足够小时才使用有界定时回退;无线转换为 seat-ota MOVE |
| 坐盆/高度相对移动 | `seat passenger basin relative <up\|down> <1-100>` | 同上 | 同上 |
| 水平相对移动 | `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;无线:seat-ota `0x6A0` DYNAMIC,运行状态以 `0x306` 或 OTA 应答确认 |
| 摇篮模式 | `seat passenger dynamic cradle` | 固件模式 ID 2 | 有线:`0x300` Opcode `0x0E`、ParamA=2;无线:seat-ota `0x6A0` DYNAMIC,运行状态以 `0x306` 或 OTA 应答确认 |
| 停止动态模式 | `seat passenger dynamic stop` | 无附加参数 | 有线:`0x300` Opcode `0x0F`;无线:seat-ota STOP ALL |
| 木马模式 | `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 控制指令”统一显示上表规范形式:
@@ -38,11 +38,11 @@ CAN0 与 CAN1 必须分开解释。本文中的 `seat passenger ...` 只生成
| Byte | 字段 | 说明 |
| ---: | --- | --- |
| 0 | Opcode | `0x01` 绝对、`0x02` 固件原生相对、`0x0E` 启动动态、`0x0F` 停止动态 |
| 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;动态模式为 1/2 |
| 6..7 | ParamB | 绝对/相对超时 0 或 100..60000 ms;动态模式必须为 0 |
| 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`。这是执行策略与语义命令的区别:用户仍能看到相对语义,底层可根据位置是否有效选择更安全的原始帧序列。
@@ -59,6 +59,10 @@ CAN0 与 CAN1 必须分开解释。本文中的 `seat passenger ...` 只生成
| `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`。前端只把这些物理来源显示为实车反馈。
## 自然语言示例
@@ -72,6 +76,6 @@ CAN0 与 CAN1 必须分开解释。本文中的 `seat passenger ...` 只生成
## 安全边界
- 百分比越界、非法方向、未知轴和不在枚举内的动态模式会在生成 CAN 帧前拒绝。
- 相对移动缺少有效 CAN0 当前位置时拒绝,不使用虚构位置计算目标。
- 相对移动不会使用虚构位置计算目标;位置无效时仅允许编译器已限制时长、方向和 Duty 的 `0x03` 回退,否则拒绝。
- MQTT Broker 接受消息只代表已提交;车辆动作是否完成仍以 CAN0 状态反馈为准。
- 固件还实现校准、清故障、查询、停止单电机等维护 Opcode,但它们不属于面向自然语言和前端设备控件的控制白名单。
- 固件还实现全量程校准、清故障和查询等维护 Opcode,但它们不属于面向自然语言和前端设备控件的控制白名单。
+16 -2
View File
@@ -20,6 +20,7 @@
| 空调温度 | `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` |
@@ -41,6 +42,19 @@
| 遮阳帘动作 | `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 都不能替代实体风口动作核对。
## 风口标定坐标范围
| 风口 | 左右范围 | 上下范围 |
@@ -62,7 +76,7 @@
## CAN0 边界
本文档只描述 CAN1 车身控制白名单。CAN0 只用于副驾座椅,其状态、轴位置、热舒适和动态模式控制必须依据 `seat-ota` 协议独立处理,不得使用本 CAN1 白名单生成或猜测 CAN0 帧。
本文档只描述 CAN1 车身控制白名单。CAN0 只用于副驾座椅,其状态、轴位置、热舒适和动态模式控制必须依据 `/home/orin/project511/passenger_seat_firmware` 中的固件原生协议独立处理,不得使用本 CAN1 白名单生成或猜测 CAN0 帧。
## 实现与回归测试
@@ -71,4 +85,4 @@
- 风口标定范围:`platforms/t2g_cabin_bench/calibration/body_can.yaml`
- 完整白名单回归测试:`tests/unit/test_can1_semantic_whitelist.py`
白名单回归测试展开覆盖 119 条有效命令,并验证越界、非整数、非法枚举和超出风口标定范围的指令不能通过白名单门禁。
白名单回归测试展开覆盖 129 条有效命令(包括内/外循环和 8 条单风口开关组合),并验证越界、非整数、非法枚举和超出风口标定范围的指令不能通过白名单门禁。`can_read_trans self-test` 还会将这 129 条指令按 Console 单批 32 条的上限拆成 5 批,逐条验证 CAN1 路由,并用一批 30 条指令覆盖所有 CAN1 控制类别的混合校验。
@@ -1 +0,0 @@
,orin,ubuntu,25.08.2026 09:37,file:///home/orin/.config/libreoffice/4;
+208
View File
@@ -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、
+41
View File
@@ -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、应用反馈和实体动作。
+28 -20
View File
@@ -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`。本地有人
值守台架可以显式关闭逐请求令牌;共享或远程环境应恢复令牌门禁。
启动:
@@ -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 "
+486 -29
View File
@@ -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,10 +654,89 @@ 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)
@@ -383,6 +745,11 @@ class HardwareSeatExecutor:
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",
@@ -532,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,
@@ -635,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:
@@ -655,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()
@@ -686,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_")
@@ -703,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:
@@ -758,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"]
@@ -780,7 +1225,7 @@ class HardwareSeatExecutor:
status_values, status_frame = self._receive_until(
bus,
0x306,
dynamic_feedback_id,
int(completion["timeout_ms"]),
matches,
)
@@ -916,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:
@@ -941,7 +1392,7 @@ class HardwareSeatExecutor:
values, feedback = self._receive_until(
bus,
0x302,
motor_feedback_id,
int(command["completion"]["timeout_ms"]),
complete,
)
@@ -1389,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:
@@ -1401,7 +1858,7 @@ class HardwareSeatExecutor:
values, feedback = self._receive_until(
bus,
0x302,
motor_feedback_id,
int(command["completion"]["timeout_ms"]),
terminal,
)
+1 -1
View File
@@ -152,7 +152,7 @@ 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.
# Native CAN0 firmware axis 1 is the physical seat-height axis.
"cushion_tilt": (
"座椅高度",
"高度",
+81 -4
View File
@@ -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"])
@@ -1,393 +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": "horizontal",
"seat.set_cushion_tilt": "basin",
"seat.set_backrest_recline": "backrest",
"seat.set_legrest_height": "legrest",
}
if capability in axes:
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[axes[capability]].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 {axes[capability]} relative "
f"{direction} {int(delta)}"
)
return (
f"seat passenger {axes[capability]} position "
f"{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
"""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,
@@ -395,147 +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
thermal_publication_start: int | None = None
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 = (
thermal_publication_start
if thermal_publication_start is not None
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":
encoded = self.status_executor.matrix.encode(
command["message"], command["signals"]
)
bus.send(
int(encoded["frame_id"], 16),
bytes.fromhex(encoded["data_hex"]),
)
thermal_publication_start = publication_start
continue
if command["message"] == "T2G_Seat_Thermal_Control_29E":
command_results = self.status_executor._execute_thermal( # noqa: SLF001
bus, command, referenced
)
thermal_publication_start = None
elif command["message"] == "T2G_Seat_Move_Command_300":
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
else:
raise HardwareExecutionError(
"wireless CAN0 command is not in the passenger-seat runtime "
f"whitelist: {command['message']}"
)
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",
"seat_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]
@@ -72,6 +72,12 @@ DRIVER_SEAT_THERMAL_MIDDLE_PATTERN = re.compile(
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
)
DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN = re.compile(
r"^(?:(?:然后|随后|接着|同时|并且|再|并|以及)?(?:把|将)?(?:按摩)?)"
r"(?:模式|强度|力度|档位|等级|"
r"(?:切换|改成|改为|调整|调成|调为|设置|设为)"
r".{0,8}(?:波浪|脉冲|伸展|拉伸|松弛|舒展|放松)(?:模式)?)"
)
_FEATURE_BOUNDARY = re.compile(
r"并且|同时|然后|随后|接着|以及|并|再|和|与|、|,"
)
@@ -199,16 +205,15 @@ def _scoped_text(
}
selected_indexes = set(subject_indexes)
if continuation_pattern:
selected_indexes.update(
index + 1
for index in subject_indexes
if index + 1 < len(clauses)
and re.search(
for index in subject_indexes:
continuation_index = index + 1
while continuation_index < len(clauses) and re.search(
continuation_pattern,
clauses[index + 1],
clauses[continuation_index],
re.IGNORECASE,
)
)
):
selected_indexes.add(continuation_index)
continuation_index += 1
return ",".join(
clause
for index, clause in enumerate(clauses)
@@ -360,6 +365,12 @@ def _parse_exact_command(text: str) -> list[dict[str, Any]]:
if match:
return [_action("climate", "climate.set_front_defrost", "vehicle", {"state": match[1]}, "用户输入了受信任的空调前除霜命令。")]
match = re.fullmatch(
r"outlet (driver-left|driver-right|passenger-left|passenger-right) (open|close)",
command,
)
if match:
return [_action("outlet", "outlet.set_switch_state", match[1].replace("-", "_"), {"state": match[2]}, "用户输入了受信任的单风口开关命令。")]
match = re.fullmatch(
r"outlet (driver-left|driver-right|passenger-left|passenger-right) percent (\d+) (\d+)",
command,
@@ -529,7 +540,26 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
inherited_driver_scope = False
combined_actions: list[dict[str, Any]] = []
seen: set[tuple[str, str, tuple[tuple[str, str], ...]]] = set()
for clause in seat_clauses:
index = 0
while index < len(seat_clauses):
clause = seat_clauses[index]
if "按摩" in clause:
continuation_index = index + 1
while (
continuation_index < len(seat_clauses)
and DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN.search(
seat_clauses[continuation_index]
)
):
continuation = re.sub(
r"^(?:然后|随后|接着|同时|并且|再|并|以及)?"
r"(?:把|将)?(?:按摩)?",
"",
seat_clauses[continuation_index],
)
clause = f"{clause}{continuation}"
continuation_index += 1
index = continuation_index - 1
if re.search(r"主驾|驾驶位|司机位|左前座椅", clause):
inherited_driver_scope = True
elif re.search(r"副驾|副驾驶|乘客|右前", clause):
@@ -550,6 +580,7 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
if signature not in seen:
seen.add(signature)
combined_actions.append(action)
index += 1
return combined_actions
if re.search(
@@ -626,7 +657,7 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
))
if "按摩" in compact:
massage_text = _feature_context(compact, "按摩")
massage_text = _feature_context(compact, "按摩", radius=48)
massage_actions: list[dict[str, Any]] = []
if re.search(r"关闭|关掉|停止|取消|不要|别开", massage_text):
massage_actions.append({"action": "off"})
@@ -922,6 +953,7 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
compact,
r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|按摩|记忆|预设|"
r"通风|加热|前移|后移|向前移动|向后移动",
DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN.pattern,
)
actions: list[dict[str, Any]] = _parse_chinese_driver_seat_commands(
seat_text
@@ -1009,9 +1041,18 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
elif re.search(r"外循环", climate_text):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。"))
has_face = _positive_outlet_feature(climate_text, r"吹面|吹脸")
has_feet = _positive_outlet_feature(climate_text, r"吹脚")
has_defrost = _positive_outlet_feature(climate_text, r"除霜")
# A phrase such as “打开副驾左吹面出风口” controls one motorized
# outlet; “吹面” inside that device name must not also select the
# vehicle-wide HVAC face mode. Remove only explicit four-outlet
# device names, leaving an independent “空调设为吹面” clause intact.
climate_mode_text = re.sub(
r"(?:主驾左|主驾右|副驾左|副驾右)(?:吹面)?(?:出)?风口",
"",
climate_text,
)
has_face = _positive_outlet_feature(climate_mode_text, r"吹面|吹脸")
has_feet = _positive_outlet_feature(climate_mode_text, r"吹脚")
has_defrost = _positive_outlet_feature(climate_mode_text, r"除霜")
outlet_mode = (
"face_feet_defrost"
if has_face and has_feet and has_defrost
@@ -1162,7 +1203,19 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": brightness_level}, "用户指定了已验证的氛围灯亮度档位。"))
outlet_text = _scoped_text(compact, r"风口")
outlet_target = next((value for label, value in OUTLET_TARGETS.items() if f"{label}风口" in outlet_text), None)
outlet_target = next(
(
value
for label, value in OUTLET_TARGETS.items()
if re.search(rf"{label}(?:吹面)?(?:出)?风口", outlet_text)
),
None,
)
outlet_switch_state = None
if re.search(r"(?:打开|开启)(?:一下)?", outlet_text):
outlet_switch_state = "open"
elif re.search(r"(?:关闭|关掉)(?:一下)?", outlet_text):
outlet_switch_state = "close"
calibrated_position = re.search(
r"(?:标定坐标|标定位置).*?(?:左右|水平)"
r"(-?\d{1,3})(?![\d.]).*?(?:上下|垂直)(-?\d{1,3})(?![\d.])",
@@ -1173,7 +1226,17 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
r"(?:上下|垂直)(\d{1,3})%?(?![\d.])",
outlet_text,
)
if (
if outlet_target and outlet_switch_state:
actions.append(
_action(
"outlet",
"outlet.set_switch_state",
outlet_target,
{"state": outlet_switch_state},
"用户指定了一个前排电动风口及其开关状态。",
)
)
elif (
outlet_target
and calibrated_position
and all(-128 <= int(value) <= 126 for value in calibrated_position.groups())
@@ -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"))
@@ -386,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']} "
@@ -453,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 {
@@ -472,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,
@@ -494,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']}",
)
]
@@ -508,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,
@@ -530,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":
@@ -573,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
@@ -587,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},
)
@@ -624,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})
@@ -668,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
@@ -791,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
@@ -845,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(
@@ -886,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]
@@ -929,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"]),
},
)
@@ -1005,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
@@ -1052,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,
@@ -1067,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(),
},
)
@@ -1173,7 +1260,19 @@ class NativeCanExecutor:
feedback_match = self._feedback_matches(command, feedback)
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
@@ -1226,6 +1325,7 @@ class NativeCanExecutor:
"feedback_state": self._feedback_state(feedback),
"feedback_required": feedback_required,
"delivery_status": delivery_status,
"verification_status": verification_status,
"physical_effect_confirmed": verified,
"control_gate": (
"compiled_action_and_frame_whitelist"
@@ -1254,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,
},
}
+354 -49
View File
@@ -54,9 +54,12 @@ from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
SEAT_MOTION_CAPABILITIES,
)
from cognitive_agent.application.mqtt_ota_seat_executor import ( # noqa: E402
MqttOtaSeatExecutor,
MqttNativeSeatExecutor,
)
from cognitive_agent.application.native_can_intent import ( # noqa: E402
DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN,
merge_native_can_intent,
)
from cognitive_agent.application.native_can_intent import merge_native_can_intent # noqa: E402
from cognitive_agent.application.direct_can_control import ( # noqa: E402
build_direct_can_intent,
)
@@ -103,7 +106,10 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
VehicleCanSettings,
VehicleMqttSettings,
)
from cognitive_agent.infrastructure.can.gateway import ZlgChannelConfig # noqa: E402
from cognitive_agent.infrastructure.can.gateway import ( # noqa: E402
ZlgCanBridgeProcess,
ZlgChannelConfig,
)
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
MqttCanTransport,
)
@@ -153,6 +159,88 @@ def _wireless_mqtt_publish_preflight() -> dict[str, Any]:
}
EXECUTION_VERIFICATION_STATUSES = frozenset(
{"verified_success", "submitted_unverified", "failed_or_blocked"}
)
def _operation_verification_status(item: dict[str, Any]) -> str:
"""Classify one operation without conflating Tx and physical feedback."""
telemetry = item.get("telemetry", {})
explicit = str(telemetry.get("verification_status") or "")
if explicit in EXECUTION_VERIFICATION_STATUSES:
return explicit
if item.get("status") != "succeeded":
return "failed_or_blocked"
if (
telemetry.get("physical_effect_confirmed") is True
or telemetry.get("feedback_frame") is not None
or telemetry.get("hardware_feedback") is True
or telemetry.get("firmware_verified") is True
):
return "verified_success"
if (
telemetry.get("command_frame") is not None
or telemetry.get("delivery_status") in {"submitted", "queued", "published"}
):
return "submitted_unverified"
# Dry-run and control-plane operations are complete at their own evidence
# level and do not claim that a physical actuator moved.
return "verified_success"
def _annotate_execution_report(report: dict[str, Any]) -> tuple[str, bool]:
"""Add task-level evidence and normalize submission success.
Returns ``(verification_status, partially_succeeded)``. Support steps such
as firmware identity checks do not turn a failed requested operation into a
partially successful user task.
"""
spec = report.setdefault("spec", {})
results = [item for item in spec.get("results", []) if isinstance(item, dict)]
operation_results = [
item
for item in results
if item.get("ref") not in {"seat-controller-firmware"}
] or results
statuses_by_ref: dict[str, list[str]] = {}
for item in operation_results:
statuses_by_ref.setdefault(str(item.get("ref") or "operation"), []).append(
_operation_verification_status(item)
)
statuses = [
(
"failed_or_blocked"
if "failed_or_blocked" in values
else "submitted_unverified"
if "submitted_unverified" in values
else "verified_success"
)
for values in statuses_by_ref.values()
]
failed = sum(status == "failed_or_blocked" for status in statuses)
accepted = len(statuses) - failed
partially_succeeded = bool(failed and accepted)
verification_status = (
"failed_or_blocked"
if failed
else "submitted_unverified"
if any(status == "submitted_unverified" for status in statuses)
else "verified_success"
)
spec["verification_status"] = verification_status
spec["status"] = (
"partially_succeeded"
if partially_succeeded
else "failed"
if failed
else "succeeded"
)
return verification_status, partially_succeeded
def _has_mixed_driver_passenger_seat_scope(utterance: str) -> bool:
"""Detect one request that crosses the CH1-driver and CH0-passenger boundary."""
@@ -358,6 +446,17 @@ class SeatDemoService:
# the physical passenger-seat path through the shared adapter.
state_sync_info["poll_interval_ms"] = 5_000
vehicle_can_info["state_sync"] = state_sync_info
elif self.vehicle_can_settings.state_configured:
vehicle_can_info["state_sync"] = {
"mode": "on_demand_shared_adapter",
"state": "ready",
"read_only": True,
"poll_interval_ms": 5_000,
"snapshot_freshness_ms": 5_000,
"capture_sequence": None,
"observed_frame_ids": [],
"last_error": None,
}
vehicle_mqtt_info = self._vehicle_mqtt_transport.public_info()
return {
"service": "cabin-cognitive-orchestrator",
@@ -383,6 +482,16 @@ class SeatDemoService:
"id": WIRED_CAN_TRANSPORT,
"label": "有线控制",
**vehicle_can_info,
"target_policy": (
"dual_channel_wired_can0_passenger_can1_vehicle"
if self.hardware_settings.configured
and self.vehicle_can_settings.configured
else vehicle_can_info.get("target_policy")
),
"channels": {
"seat": 0,
"vehicle": self.vehicle_can_settings.channel,
},
},
{
"id": WIRELESS_MQTT_TRANSPORT,
@@ -411,7 +520,7 @@ class SeatDemoService:
and self.vehicle_can_settings.enabled
),
"active_policy": (
"dual_channel_passenger_ch0_driver_ch1"
"dual_channel_wired_can0_passenger_can1_vehicle"
if self.hardware_settings.configured
and self.vehicle_can_settings.configured
else "ch1_body_can_driver_only"
@@ -759,6 +868,7 @@ class SeatDemoService:
)
control_transport = self._validate_control_transport(control_transport)
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
wired_state_bridge = None
if mqtt_selected:
if not self.vehicle_mqtt_settings.state_configured:
raise HardwareSafetyError("the MQTT CAN0 seat relay is not configured")
@@ -768,13 +878,40 @@ class SeatDemoService:
)
else:
if not self.hardware_settings.configured:
raise HardwareSafetyError("the T2G seat hardware runtime is not configured")
bus_factory = None
raise HardwareSafetyError(
"the passenger-seat CAN0 hardware runtime is not configured"
)
if (
self.hardware_settings.native_passenger_can0
and self.vehicle_can_settings.adapter_configured
):
wired_state_bridge = self.vehicle_can_settings.bridge(
[
ZlgChannelConfig(
channel=0,
frame_format="classic_can",
arbitration_bitrate=self.hardware_settings.bitrate,
data_bitrate=self.vehicle_can_settings.data_bitrate,
can_fd_iso=True,
termination_enabled=(
self.hardware_settings.termination_enabled
),
)
],
monitor_only=True,
)
bus_factory = lambda: wired_state_bridge.channel_bus(0)
else:
bus_factory = None
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
physical_state = self._new_hardware_executor(
bus_factory,
remote_transport=mqtt_selected,
).read_physical_state()
try:
physical_state = self._new_hardware_executor(
bus_factory,
remote_transport=mqtt_selected,
).read_physical_state()
finally:
if wired_state_bridge is not None:
wired_state_bridge.close()
if mqtt_selected:
physical_state["source"] = "wireless_mqtt_ch0"
context = self.conversations.synchronize_physical_passenger(
@@ -1006,12 +1143,16 @@ class SeatDemoService:
bus_factory: Any | None = None,
*,
remote_transport: bool = False,
) -> HardwareSeatExecutor | MqttOtaSeatExecutor | ZcanBridgeSeatExecutor:
) -> HardwareSeatExecutor | MqttNativeSeatExecutor | ZcanBridgeSeatExecutor:
shared_bridge_enabled = os.getenv(
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
os.getenv("COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"),
).strip().lower() in {"1", "true", "yes", "on"}
if bus_factory is None and shared_bridge_enabled:
if (
bus_factory is None
and shared_bridge_enabled
and self.hardware_settings.backend != "zlgcan-linux"
):
# Import lazily so the ordinary dry-run service does not initialize
# any self-repair runtime state. Both modules then share the one
# process that is allowed to own the Windows ZLG adapter.
@@ -1054,22 +1195,72 @@ class SeatDemoService:
stop_all_motor_selector=int(
self.hardware_compiler.protocol["stop_all_motor_selector"]
),
auto_home=self.hardware_settings.auto_home_enabled,
auto_home=(
self.hardware_settings.auto_home_enabled
if not remote_transport
else False
),
home_timeout_s=self.hardware_settings.home_timeout_s,
firmware_guard=self.hardware_settings.firmware_guard(
resolved_bus_factory,
allow_programming=not remote_transport,
firmware_guard=(
None
if remote_transport
or self.hardware_settings.native_passenger_can0
else self.hardware_settings.firmware_guard(
resolved_bus_factory,
allow_programming=not remote_transport,
)
),
synchronize_position_before_first_motion=(
self.hardware_settings.position_sync_enabled
if not remote_transport
else False
),
position_sync_state=self._seat_position_memory_state,
require_thermal_feedback=not remote_transport,
feedback_required=not remote_transport,
)
if remote_transport:
return MqttOtaSeatExecutor(executor, resolved_bus_factory)
return MqttNativeSeatExecutor(executor, resolved_bus_factory)
return executor
def _new_linux_wired_dual_channel_bridge(self) -> ZlgCanBridgeProcess:
"""Open CAN0 passenger classic CAN and CAN1 body CAN FD under one owner."""
if not self.hardware_settings.native_passenger_can0:
raise HardwareSafetyError(
"the shared Linux dual-channel bridge requires native passenger CAN0"
)
if not self.vehicle_can_settings.configured:
raise HardwareSafetyError(
"the shared Linux dual-channel bridge requires configured vehicle CAN1"
)
if (
self.hardware_settings.device_type
!= self.vehicle_can_settings.device_type
or self.hardware_settings.device_index
!= self.vehicle_can_settings.device_index
or self.hardware_settings.zlg_dll.resolve()
!= self.vehicle_can_settings.zlg_dll.resolve()
):
raise HardwareSafetyError(
"CAN0 and CAN1 must use the same Linux ZLG device and SDK library"
)
return self.vehicle_can_settings.bridge(
[
ZlgChannelConfig(
channel=0,
frame_format="classic_can",
arbitration_bitrate=self.hardware_settings.bitrate,
data_bitrate=self.vehicle_can_settings.data_bitrate,
can_fd_iso=True,
termination_enabled=(
self.hardware_settings.termination_enabled
),
),
self.vehicle_can_settings.channel_config(),
]
)
def preview_native_can(
self,
utterance: str,
@@ -1867,10 +2058,11 @@ class SeatDemoService:
if parsed.get("actions") and parsed.get("native_can_actions"):
if (
control_transport == WIRED_CAN_TRANSPORT
and self.hardware_settings.backend != "zlgcan"
and self.hardware_settings.backend
not in {"zlgcan", "zlgcan-linux"}
):
raise HardwareSafetyError(
"linked seat/vehicle execution requires the shared zlgcan backend"
"linked seat/vehicle execution requires a shared ZLG backend"
)
if control_transport == WIRED_CAN_TRANSPORT:
try:
@@ -2282,7 +2474,7 @@ class SeatDemoService:
"safety_check",
"succeeded",
(
"无线 MQTT CAN0 座椅语义白名单与 seat-ota 报文白名单已通过;"
"无线 MQTT CAN0 座椅语义白名单与固件原生 0x300 报文白名单已通过;"
"本地 T2G 固件 profile、USB-CAN 与物理台架连接不参与校验。"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
@@ -2307,7 +2499,7 @@ class SeatDemoService:
simulation_fault if simulated_hardware_request else None
),
"position_calibration_gate": (
"remote_seat_ota_feedback"
"remote_native_can0_feedback"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
else "runtime_can_preflight"
@@ -2315,7 +2507,7 @@ class SeatDemoService:
else "not_applicable"
),
"mapping_status": (
"mqtt_can0_seat_ota_whitelist"
"mqtt_can0_native_firmware_whitelist"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
else "protocol_hil_verified_combined_firmware_hil_pending"
@@ -2335,17 +2527,47 @@ class SeatDemoService:
if hardware_request:
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
vehicle_safety_preflight = None
if mqtt_selected:
vehicle_safety_preflight = _wireless_mqtt_publish_preflight()
hardware_executor = self._new_hardware_executor(
lambda: self._vehicle_mqtt_transport.bus(channel=0),
remote_transport=True,
wired_bridge = None
try:
if mqtt_selected:
vehicle_safety_preflight = _wireless_mqtt_publish_preflight()
hardware_executor = self._new_hardware_executor(
lambda: self._vehicle_mqtt_transport.bus(channel=0),
remote_transport=True,
)
elif (
self.hardware_settings.native_passenger_can0
and self.vehicle_can_settings.configured
):
wired_bridge = self._new_linux_wired_dual_channel_bridge()
vehicle_safety_preflight = observe_vehicle_safety(
wired_bridge.channel_bus(1),
timeout_ms=min(
5_000,
max(
1_000,
self.vehicle_can_settings.feedback_timeout_ms,
),
),
required_channel=1,
)
require_stationary_vehicle(vehicle_safety_preflight)
hardware_executor = self._new_hardware_executor(
lambda: wired_bridge.channel_bus(0)
)
else:
hardware_executor = self._new_hardware_executor()
execution_report = hardware_executor.execute(
command_plan, function_config, context["devices"]
)
else:
hardware_executor = self._new_hardware_executor()
execution_report = hardware_executor.execute(
command_plan, function_config, context["devices"]
)
except VehicleSafetyPreflightError as exc:
raise HardwareSafetyError(
"CH1 vehicle safety preflight refused passenger CAN0 execution: "
f"{exc}"
) from exc
finally:
if wired_bridge is not None:
wired_bridge.close()
if vehicle_safety_preflight is not None:
for item in execution_report["spec"].get("results", []):
item.setdefault("telemetry", {})[
@@ -2362,14 +2584,21 @@ class SeatDemoService:
execution_report = self.executor.execute(
command_plan, function_config, context["devices"]
)
execution_outcome, execution_partially_succeeded = (
_annotate_execution_report(execution_report)
)
report_issues = self.contracts.validate(execution_report)
validation.extend(report_issues)
result["execution_report"] = execution_report
result["execution_outcome"] = execution_outcome
execution_succeeded = (
not report_issues and execution_report["spec"]["status"] == "succeeded"
not report_issues
and execution_report["spec"]["status"] == "succeeded"
)
if report_issues:
result["status"] = "execution_report_invalid"
elif execution_partially_succeeded:
result["status"] = "partially_succeeded"
elif not execution_succeeded:
result["status"] = "execution_failed"
elif parsed.get("deferred_requests"):
@@ -2406,7 +2635,11 @@ class SeatDemoService:
"execute",
"succeeded" if execution_succeeded else "failed",
(
"真实 CAN 命令已发送,并收到 IAR 固件的实际状态回读。"
"真实 CAN 命令已提交;当前反馈窗口未确认车辆效果,状态以实时 CAN 回读为准。"
if hardware_request
and execution_succeeded
and execution_outcome == "submitted_unverified"
else "真实 CAN 命令已发送,并收到 IAR 固件的实际状态回读。"
if hardware_request and execution_succeeded
else (
(
@@ -2608,12 +2841,18 @@ class SeatDemoService:
feedback_by_ref = {
item["ref"]: item.get("telemetry", {})
for item in execution_report["spec"]["results"]
if item.get("status") == "succeeded"
if _operation_verification_status(item) == "verified_success"
}
updated = deepcopy(actions)
updated: list[dict[str, Any]] = []
operations = function_config["spec"]["operations"]
for action, operation in zip(updated, operations, strict=True):
telemetry = feedback_by_ref.get(operation["id"], {})
for source_action, operation in zip(actions, operations, strict=True):
telemetry = feedback_by_ref.get(operation["id"])
if telemetry is None:
# Submission alone is not an authoritative vehicle-state
# update. The asynchronous CAN/MQTT state monitor will apply
# the change if and when the vehicle reports it.
continue
action = deepcopy(source_action)
if action.get("action_type") == "position":
if telemetry.get("effective_position_pct") is not None:
action["position_pct"] = int(
@@ -2633,12 +2872,14 @@ class SeatDemoService:
if telemetry.get("motion_mode") == "timed_user_request"
else "relative_hall_confirmed_open_loop"
)
updated.append(action)
continue
raw = telemetry.get("feedback_value")
if raw is not None:
raw_value = int(raw)
action["state"] = "off" if raw_value <= 1 else "on"
action["requested_level"] = max(0, raw_value - 1)
updated.append(action)
physical_state = execution_report.get("spec", {}).get("physical_state")
if isinstance(physical_state, dict):
@@ -4259,6 +4500,8 @@ class SeatDemoService:
simulation_fault=simulation_fault,
control_transport=control_transport,
)
for domain_report in reports.values():
_annotate_execution_report(domain_report)
report_results = [
item
for domain_report in reports.values()
@@ -4276,7 +4519,7 @@ class SeatDemoService:
domain
for domain in operation_domains
if all(
item.get("status") == "succeeded"
_operation_verification_status(item) != "failed_or_blocked"
for item in operation_results
if str(item.get("domain")) == domain
)
@@ -4284,13 +4527,29 @@ class SeatDemoService:
failed_domains = {
str(item.get("domain"))
for item in operation_results
if item.get("status") == "failed"
if _operation_verification_status(item) == "failed_or_blocked"
}
execution_succeeded = all(
domain_report["spec"]["status"] == "succeeded"
for domain_report in reports.values()
) and all(item.get("status") == "succeeded" for item in report_results)
execution_succeeded = not failed_domains
execution_partially_succeeded = bool(successful_domains and failed_domains)
execution_outcome = (
"failed_or_blocked"
if failed_domains
else "submitted_unverified"
if any(
_operation_verification_status(item) == "submitted_unverified"
for item in operation_results
)
else "verified_success"
)
verified_domains = {
domain
for domain in operation_domains
if all(
_operation_verification_status(item) == "verified_success"
for item in operation_results
if str(item.get("domain")) == domain
)
}
physical_state = (
reports.get("seat", {}).get("spec", {}).get("physical_state")
if hardware_request
@@ -4299,7 +4558,14 @@ class SeatDemoService:
simulated_seat = simulated_hardware_request and "seat" in reports
report_spec: dict[str, Any] = {
"mode": "hil" if hardware_request or simulated_seat else "dry_run",
"status": "succeeded" if execution_succeeded else "failed",
"status": (
"partially_succeeded"
if execution_partially_succeeded
else "succeeded"
if execution_succeeded
else "failed"
),
"verification_status": execution_outcome,
"started_at": execution_started_at,
"finished_at": _now(),
"results": report_results,
@@ -4352,6 +4618,7 @@ class SeatDemoService:
report_issues = self.contracts.validate(execution_report)
validation.extend(report_issues)
result["execution_report"] = execution_report
result["execution_outcome"] = execution_outcome
result["validation"] = validation
if report_issues:
result["status"] = "execution_report_invalid"
@@ -4376,6 +4643,11 @@ class SeatDemoService:
"无线 MQTT CAN0/CAN1 白名单控制帧已提交;"
"车辆实际状态以状态话题回读为准。"
if wireless_hardware_request and execution_succeeded
else "有线 CAN0/CAN1 白名单控制帧已提交;"
"当前反馈窗口未确认车辆效果,状态以实时 CAN 回读为准。"
if hardware_request
and execution_succeeded
and execution_outcome == "submitted_unverified"
else "CH1 车身 CAN 动作已按目标字段物理回读闭环执行。"
if hardware_request and native_config_keys and execution_succeeded
else "副驾座椅已按独立 T2G 物理 CAN 回读闭环执行。"
@@ -4418,6 +4690,7 @@ class SeatDemoService:
"successful_domains": sorted(successful_domains),
"failed_domains": sorted(failed_domains),
"partially_succeeded": execution_partially_succeeded,
"execution_outcome": execution_outcome,
},
domain="orchestration",
)
@@ -4439,8 +4712,17 @@ class SeatDemoService:
*[
action
for action in parsed.get("domain_actions", [])
if execution_succeeded
or str(action.get("domain")) in successful_domains
if (
not hardware_request
and (
execution_succeeded
or str(action.get("domain")) in successful_domains
)
)
or (
hardware_request
and str(action.get("domain")) in verified_domains
)
],
*[
{
@@ -4550,8 +4832,16 @@ class SeatDemoService:
vehicle_safety_preflight: dict[str, Any] | None = None
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
wired_native_passenger = bool(
not mqtt_selected
and "seat" in configs
and self.hardware_settings.native_passenger_can0
and self.vehicle_can_settings.configured
)
if hardware_request and (
native_config_keys or (mqtt_selected and "seat" in configs)
native_config_keys
or (mqtt_selected and "seat" in configs)
or wired_native_passenger
):
shared_runtime_enabled = (
not mqtt_selected
@@ -4584,6 +4874,10 @@ class SeatDemoService:
# on CH0; passing a raw bus factory here would bypass its one-
# owner queue and allow self-repair to race orchestration.
seat_bus_factory = None
elif wired_native_passenger:
bridge = self._new_linux_wired_dual_channel_bridge()
native_bus_factory = lambda: bridge.channel_bus(1)
seat_bus_factory = lambda: bridge.channel_bus(0)
else:
channel_configs = [self.vehicle_can_settings.channel_config()]
if "seat" in configs:
@@ -4779,11 +5073,22 @@ class SeatDemoService:
]
clauses: list[str] = []
for fragment in fragments:
# A massage mode/strength phrase may omit both "主驾" and "按摩"
# after the first clause. Keep the whole request atomic so the ON
# fallback does not emit relax before an explicit later mode.
massage_continuation = bool(
clauses
and "按摩" in clauses[-1]
and DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN.search(fragment)
)
# An exclusion is a qualifier of the control immediately before it,
# not an independent command. Keeping both pieces together lets
# the deterministic window parser remove the excluded targets
# before the command list is finalized.
if clauses and re.search(r"除外|除去|不包括|不含", fragment):
exclusion_qualifier = bool(
clauses and re.search(r"除外|除去|不包括|不含", fragment)
)
if massage_continuation or exclusion_qualifier:
clauses[-1] = f"{clauses[-1]},{fragment}"
else:
clauses.append(fragment)
@@ -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,7 +42,9 @@ 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})
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
@@ -60,10 +62,12 @@ MQTT_ACCEPTED_FEEDBACK_IDS = {
1: MQTT_VEHICLE_FEEDBACK_IDS,
}
MQTT_APPROVED_CONTROL_IDS = {
0: frozenset({0x29E, 0x3CE, 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")
@@ -192,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 (
@@ -215,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
@@ -225,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)
@@ -348,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
@@ -363,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:
@@ -428,31 +498,60 @@ 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:
@@ -819,8 +918,8 @@ 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 and frame.arbitration_id == 0x6A0:
_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:
+86 -51
View File
@@ -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,
)
@@ -387,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:
@@ -536,6 +541,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
{
"error": "hardware_access_required",
"message": str(exc),
"outcome": "failed_or_blocked",
"access_state": exc.state,
},
HTTPStatus.FORBIDDEN,
@@ -546,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,
@@ -554,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)
@@ -567,6 +595,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
{
"error": "openai_request_failed",
"message": "GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
"outcome": "failed_or_blocked",
},
HTTPStatus.BAD_GATEWAY,
)
@@ -577,6 +606,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
{
"error": "internal_error",
"message": "请求处理失败,请查看服务端日志。",
"outcome": "failed_or_blocked",
"detail": str(exc),
},
HTTPStatus.INTERNAL_SERVER_ERROR,
)
@@ -618,6 +649,36 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
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":
@@ -663,63 +724,37 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
emit({"type": "result", "payload": result})
except HardwareAccessRequiredError as exc:
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": "hardware_access_required", "message": str(exc)},
emit_terminal_error(
"hardware_access_required",
str(exc),
extra={"access_state": exc.state},
)
emit({
"type": "error",
"error": "hardware_access_required",
"message": str(exc),
"access_state": exc.state,
})
except HardwareBusyError as exc:
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": "hardware_busy", "message": str(exc)},
emit_terminal_error(
"hardware_busy",
str(exc),
extra={"retry_after_seconds": exc.retry_after_seconds},
)
emit({
"type": "error",
"error": "hardware_busy",
"message": str(exc),
"retry_after_seconds": exc.retry_after_seconds,
})
except HardwareSafetyError as exc:
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": "hardware_safety_gate", "message": str(exc)},
)
emit({
"type": "error",
"error": "hardware_safety_gate",
"message": str(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({
"type": "error",
"error": "invalid_request",
"message": str(exc),
})
emit_terminal_error("invalid_request", str(exc))
except OpenAIIntentError as exc:
self.log_error("OpenAI intent request failed: %s", exc)
emit({
"type": "error",
"error": "openai_request_failed",
"message": "GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
})
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({
"type": "error",
"error": "internal_error",
"message": "请求处理失败,请查看服务端日志。",
})
emit_terminal_error(
"internal_error",
"请求处理失败,请查看服务端日志。",
detail=str(exc),
)
def do_DELETE(self) -> None: # noqa: N802 - stdlib handler API
parsed_url = urlparse(self.path)
+82 -9
View File
@@ -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,14 +334,14 @@ 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-103", 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-43", 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(
@@ -378,11 +380,11 @@ 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-103"', 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-43"', 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)
@@ -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-v204"', 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-103", timeout=3
f"{self.base_url}/app.js?v=dual-seat-107", timeout=3
) as response:
app_script = response.read().decode("utf-8")
@@ -1442,6 +1460,61 @@ operations:
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
@@ -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"],
+43 -1
View File
@@ -105,6 +105,17 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
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 (
@@ -196,7 +207,7 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
def test_every_documented_exact_command_parses_and_compiles(self) -> None:
commands = self._valid_exact_commands()
self.assertEqual(len(commands), 119)
self.assertEqual(len(commands), 129)
for command in commands:
with self.subTest(command=command):
@@ -223,6 +234,7 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
"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",
@@ -258,6 +270,32 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
):
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"]),
@@ -281,6 +319,10 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
("后排空调温度设为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"]),
+74 -3
View File
@@ -235,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",
@@ -453,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(
@@ -596,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"]
)
@@ -652,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(
@@ -662,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(
+255 -8
View File
@@ -21,7 +21,6 @@ from cognitive_agent.infrastructure.can.mqtt_transport import (
parse_mqtt_can_payload,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
@@ -264,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 (
(
@@ -507,6 +589,63 @@ class MqttCanTransportTests(unittest.TestCase):
)
)
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",
@@ -620,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")
@@ -820,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)
@@ -830,6 +997,55 @@ 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
@@ -1151,11 +1367,12 @@ 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"])
@@ -1177,19 +1394,49 @@ class MqttCanTransportTests(unittest.TestCase):
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)
+435 -257
View File
@@ -1,53 +1,123 @@
from __future__ import annotations
import tempfile
import unittest
from collections import deque
from pathlib import Path
import tempfile
from types import SimpleNamespace
from unittest.mock import patch
from cognitive_agent.application.seat_service import SeatDemoService
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
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
MAX_HALL = {0: 480, 1: 374, 2: 917, 3: 647}
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,
)
class _NativeFirmwareBus:
"""Deterministic firmware twin; it never opens or writes a physical bus."""
class _FakeOtaBus:
def __init__(self, initial_frames: list[CanFrame]) -> None:
self.frames = deque(initial_frames)
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,
@@ -64,27 +134,79 @@ class _FakeOtaBus:
bitrate_switch=bitrate_switch,
)
self.sent.append(frame)
if arbitration_id != 0x6A0:
return 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:
@@ -95,96 +217,87 @@ class _FakeOtaBus:
self.closed = True
class _StatusExecutor:
class _Matrix:
@staticmethod
def encode(message: str, signals: dict[str, int]) -> dict[str, str]:
if message == "T2G_Seat_Thermal_Mode_3CE":
return {
"frame_id": "0x3CE",
"data_hex": f"{int(signals['Driver_Seat_Thermal_Mode']) << 4:02X} 00 00 00 00 00 00 00",
}
byte0 = (
int(signals["Driver_Seat_Ventilation_On"])
| (int(signals["Driver_Seat_Ventilation_Mode"]) << 2)
| (int(signals["Driver_Seat_Heating_On"]) << 4)
)
byte2 = (
int(signals["Driver_Seat_Ventilation_Gear"])
| (int(signals["Driver_Seat_Heating_Gear"]) << 4)
)
return {
"frame_id": "0x29E",
"data_hex": f"{byte0:02X} 00 {byte2:02X} 00 00 00 00 00",
}
class _PublishOnlyNativeFirmwareBus(_NativeFirmwareBus):
"""Accept every Tx publication but expose no synchronous state feedback."""
matrix = _Matrix()
def read_physical_state(self) -> dict[str, object]:
return {"position": {}, "complete": False}
def _capture_physical_state(
self,
_bus: _FakeOtaBus,
_results: list[dict[str, object]],
) -> dict[str, object]:
return {"position": {}, "complete": True, "missing": []}
def _execute_thermal(
self,
bus: _FakeOtaBus,
command: dict[str, object],
operations: list[dict[str, object]],
) -> list[dict[str, object]]:
encoded = self.matrix.encode(
str(command["message"]),
command["signals"], # type: ignore[arg-type]
)
sent = bus.send(
int(encoded["frame_id"], 16),
bytes.fromhex(encoded["data_hex"]),
)
feedback = bus.receive(100)
if feedback is None or feedback.arbitration_id != 0x4FA:
raise RuntimeError("missing thermal feedback")
return [
{
"ref": str(operation["id"]),
"domain": "seat",
"status": "succeeded",
"message": "thermal feedback confirmed",
"telemetry": {
"command_frame": sent.public(),
"feedback_frame": feedback.public(),
},
}
for operation in operations
]
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_wireless_hardware_service_skips_local_t2g_profile_and_bench(self) -> None:
bus = _FakeOtaBus([_axis_frame(0, 4_000)])
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
_StatusExecutor(),
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,
@@ -219,58 +332,111 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
result = service.process(
"把副驾座椅水平位置调到35%",
provider="local",
conversation_id="conv-wireless-can0-no-local-hardware",
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(list(result["command_plans"]), ["seat"])
self.assertEqual(len(validated_events), 1)
self.assertEqual(list(validated_events[0]["command_plans"]), ["seat"])
self.assertEqual(
result["command_plans"]["seat"]["spec"]["commands"][0]["message"],
"T2G_Seat_Move_Command_300",
)
self.assertEqual(bus.sent[0].arbitration_id, 0x6A0)
self.assertEqual(bus.sent[0].data[2], 0x64)
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 350)
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_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(),
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}
)
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,
},
}
]
}
}
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},
@@ -278,71 +444,78 @@ 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 horizontal 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_uses_native_firmware_frames(self) -> None:
bus = _FakeOtaBus(
[CanFrame(0x4FA, bytes((0x11, 0, 0, 0, 0, 0, 0, 0)), channel=0)]
)
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": {
@@ -352,74 +525,79 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
"Driver_Seat_Ventilation_Gear": 0,
"Driver_Seat_Heating_Gear": 1,
},
}
"send_offset_ms": 20,
},
]
}
}
report = executor.execute(
plan,
_function_config("seat.set_heating", {"state": "on", "level": 1}),
)
with patch("seat_domain.hardware_executor.time.sleep"):
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(
bus.debug_commands,
["seat passenger heat low", "seat passenger heat low"],
[item["payload"]["canId"] for item in result["telemetry"]["mqtt_messages"]],
["0x3CE", "0x29E"],
)
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 "
+ ("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__":
+39
View File
@@ -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(
+9 -2
View File
@@ -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(
@@ -231,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 ${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
@@ -303,7 +304,7 @@ 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",
},
{
@@ -532,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()
+241
View File
@@ -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()
+32 -1
View File
@@ -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')
+60
View File
@@ -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
+534
View File
@@ -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())
+34 -1
View File
@@ -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