优化了副驾座椅控制

This commit is contained in:
minghanchen
2026-08-25 11:39:41 +08:00
parent 33c92d1352
commit 9ac8fdface
34 changed files with 31714 additions and 289 deletions
+62 -3
View File
@@ -75,6 +75,7 @@ const state = {
modeDraftCommand: "", modeDraftCommand: "",
lastSuccessfulUtterance: "", lastSuccessfulUtterance: "",
modeEditor: null, modeEditor: null,
directCanCommandPending: false,
}; };
const $ = (selector, root = document) => root.querySelector(selector); const $ = (selector, root = document) => root.querySelector(selector);
@@ -7144,7 +7145,7 @@ function authoritativeCh1StatePatch(value) {
function physicalSeatStatePatch(value) { function physicalSeatStatePatch(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const source = value.source || value.feedback_source; const source = value.source || value.feedback_source;
if (["physical_can", "physical_can_ch0", "physical_partial"].includes(source)) { if (["physical_can", "physical_can_ch0", "wireless_mqtt_ch0", "physical_partial"].includes(source)) {
return value; return value;
} }
const patch = {}; const patch = {};
@@ -7385,6 +7386,56 @@ async function postControlPhase(path, body, executionMode, onEvent = null) {
return payload; return payload;
} }
async function executeCanCommands(canCommands, { deviceName = "车辆设备" } = {}) {
const commands = Array.isArray(canCommands)
? canCommands.map((command) => String(command || "").trim()).filter(Boolean)
: [];
if (!commands.length) throw new Error("没有可发送的 CAN 白名单控制指令");
if (state.directCanCommandPending) throw new Error("上一组 CAN 控制指令仍在执行,请稍候");
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
if (state.executionMode === "hardware"
&& state.controlTransport !== "wireless_mqtt"
&& !driverOnlyCh1
&& !isLocalDecisionHost()
&& state.hardwareAccess?.state !== "approved") {
throw new Error(state.hardwareAccess?.state === "pending"
? "申请仍在等待本地审批"
: "请先申请并获得本地真实台架使用权");
}
state.directCanCommandPending = true;
try {
const payload = await postControlPhase(
"/api/can/commands",
{
commands,
client_type: "web_console_device_control",
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
execution_mode: state.executionMode,
control_transport: state.controlTransport,
...(state.executionMode === "hardware"
&& state.controlTransport !== "wireless_mqtt"
&& !driverOnlyCh1
? { hardware_client_id: state.hardwareClientId }
: {}),
...(state.executionMode === "simulated_hardware"
? { simulation_fault: $("#simulationFault").value }
: {}),
},
state.executionMode,
);
if (!payload) throw new Error("CAN 控制请求未返回结果");
renderResult(payload);
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
showToast(`${deviceName} CAN 控制已发送`);
return payload;
} finally {
state.directCanCommandPending = false;
}
}
async function submitRequest(event) { async function submitRequest(event) {
event.preventDefault(); event.preventDefault();
const utterance = $("#utterance").value.trim(); const utterance = $("#utterance").value.trim();
@@ -7397,6 +7448,7 @@ async function submitRequest(event) {
} }
const driverOnlyCh1 = isDriverOnlyCh1Runtime(); const driverOnlyCh1 = isDriverOnlyCh1Runtime();
if (state.executionMode === "hardware" if (state.executionMode === "hardware"
&& state.controlTransport !== "wireless_mqtt"
&& !driverOnlyCh1 && !driverOnlyCh1
&& !isLocalDecisionHost() && !isLocalDecisionHost()
&& state.hardwareAccess?.state !== "approved") { && state.hardwareAccess?.state !== "approved") {
@@ -7430,7 +7482,9 @@ async function submitRequest(event) {
execution_mode: state.executionMode, execution_mode: state.executionMode,
control_transport: state.controlTransport, control_transport: state.controlTransport,
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}), ...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
...(state.executionMode === "hardware" && !driverOnlyCh1 ...(state.executionMode === "hardware"
&& state.controlTransport !== "wireless_mqtt"
&& !driverOnlyCh1
? { hardware_client_id: state.hardwareClientId } ? { hardware_client_id: state.hardwareClientId }
: {}), : {}),
...(state.executionMode === "simulated_hardware" ...(state.executionMode === "simulated_hardware"
@@ -7466,7 +7520,9 @@ async function submitRequest(event) {
client_type: "web_console", client_type: "web_console",
provider: state.provider, provider: state.provider,
reasoning_effort: state.reasoningEffort, reasoning_effort: state.reasoningEffort,
...(state.executionMode === "hardware" && !driverOnlyCh1 ...(state.executionMode === "hardware"
&& state.controlTransport !== "wireless_mqtt"
&& !driverOnlyCh1
? { hardware_client_id: state.hardwareClientId } ? { hardware_client_id: state.hardwareClientId }
: {}), : {}),
}, },
@@ -7630,6 +7686,9 @@ window.CognitiveAgentConsole = {
if (autoSubmit) $("#requestForm").requestSubmit(); if (autoSubmit) $("#requestForm").requestSubmit();
return true; return true;
}, },
executeCanCommands(canCommands, options = {}) {
return executeCanCommands(canCommands, options);
},
getExecutionMode() { getExecutionMode() {
return state.executionMode; return state.executionMode;
}, },
+6
View File
@@ -7219,6 +7219,12 @@ body.device-power-drawer-open {
border-color: rgba(8, 118, 111, 0.14); border-color: rgba(8, 118, 111, 0.14);
} }
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-source[data-parse-source="direct_control"] {
color: #7a4b00;
background: #fff0c7;
border-color: rgba(122, 75, 0, 0.16);
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="CAN0"] { .console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="CAN0"] {
background: #f7f4ec; background: #f7f4ec;
} }
+164 -56
View File
@@ -80,8 +80,8 @@
const isPassengerSeat = new Set(["passenger", "front-right"]).has(target); const isPassengerSeat = new Set(["passenger", "front-right"]).has(target);
if (isPassengerSeat) { if (isPassengerSeat) {
const passengerAxes = { const passengerAxes = {
"seat.set_longitudinal_position": ["副驾座椅前后调节", "longitudinal", 0], "seat.set_longitudinal_position": ["副驾座椅前后调节", "horizontal", 0],
"seat.set_cushion_tilt": ["副驾座椅高度调节", "height", 1], "seat.set_cushion_tilt": ["副驾座椅高度调节", "basin", 1],
"seat.set_backrest_recline": ["副驾座椅靠背调节", "backrest", 2], "seat.set_backrest_recline": ["副驾座椅靠背调节", "backrest", 2],
"seat.set_legrest_height": ["副驾座椅腿托调节", "legrest", 3], "seat.set_legrest_height": ["副驾座椅腿托调节", "legrest", 3],
}; };
@@ -89,13 +89,38 @@
const position = boundedInteger(parameters.position_pct, 0, 100); const position = boundedInteger(parameters.position_pct, 0, 100);
if (axis && position !== null) { if (axis && position !== null) {
const targetPermille = position * 10; const targetPermille = position * 10;
const motion = operation.motion || {};
const delta = boundedInteger(motion.delta_position_pct, 1, 100);
const relativeDirections = {
horizontal: { increase: "backward", decrease: "forward" },
basin: { increase: "down", decrease: "up" },
backrest: { increase: "backward", decrease: "forward" },
legrest: { increase: "up", decrease: "down" },
};
const relativeDirection = relativeDirections[axis[1]]?.[motion.direction];
return command( return command(
axis[0], axis[0],
`seat passenger ${axis[1]} position ${position}`, motion.mode === "relative" && relativeDirection && delta !== null
? `seat passenger ${axis[1]} relative ${relativeDirection} ${delta}`
: `seat passenger ${axis[1]} position ${position}`,
"CAN0", "CAN0",
otaCan0Frame(0x64, axis[2], targetPermille), otaCan0Frame(0x64, axis[2], targetPermille),
); );
} }
if (new Set(["seat.set_ventilation", "seat.set_heating"]).has(capability)) {
const feature = capability === "seat.set_heating" ? "heat" : "vent";
const level = boundedInteger(parameters.level, 1, 3);
const setting = onOff === "off"
? "off"
: ({ 1: "low", 2: "medium", 3: "high" })[level];
if (setting) {
return command(
"副驾座椅通风/加热",
`seat passenger ${feature} ${setting}`,
"CAN0",
);
}
}
if (capability === "seat.start_dynamic_mode") { if (capability === "seat.start_dynamic_mode") {
const mode = boundedInteger(parameters.mode_id, 1, 2); const mode = boundedInteger(parameters.mode_id, 1, 2);
if (mode !== null) { if (mode !== null) {
@@ -104,7 +129,7 @@
: { name: "cradle", selector: 1 }; : { name: "cradle", selector: 1 };
return command( return command(
"副驾座椅动态模式", "副驾座椅动态模式",
`seat passenger dynamic start ${dynamic.name}`, `seat passenger dynamic ${dynamic.name}`,
"CAN0", "CAN0",
otaCan0Frame(0x66, dynamic.selector, 300), otaCan0Frame(0x66, dynamic.selector, 300),
); );
@@ -301,7 +326,7 @@
<li data-can-channel="${escapeHtml(item.channel)}"> <li data-can-channel="${escapeHtml(item.channel)}">
<span> <span>
<b class="v2-can-command-channel">${escapeHtml(item.channel)}</b> <b class="v2-can-command-channel">${escapeHtml(item.channel)}</b>
${item.parseSource ? `<b class="v2-can-command-source" data-parse-source="${escapeHtml(item.parseSource)}">${item.parseSource === "local_rule" ? "本地规则" : "云端模型"}</b>` : ""} ${item.parseSource ? `<b class="v2-can-command-source" data-parse-source="${escapeHtml(item.parseSource)}">${item.parseSource === "direct_control" ? "设备控件" : item.parseSource === "local_rule" ? "本地规则" : "云端模型"}</b>` : ""}
${escapeHtml(item.category)} ${escapeHtml(item.category)}
</span> </span>
<code>${escapeHtml(item.command)}</code> <code>${escapeHtml(item.command)}</code>
@@ -313,6 +338,30 @@
requestAnimationFrame(syncSidebarLogBaseline); requestAnimationFrame(syncSidebarLogBaseline);
} }
function renderDirectCanCommands(commands, deviceName) {
const panel = $("#v2CanCommandPanel");
const list = $("#v2CanCommandList");
const count = $("#v2CanCommandCount");
if (!panel || !list || !count) return;
const items = commands.map((command) => ({
channel: command.startsWith("seat passenger ") ? "CAN0" : "CAN1",
command,
}));
count.textContent = String(items.length);
panel.dataset.state = "ready";
list.innerHTML = `<ol>${items.map((item) => `
<li data-can-channel="${item.channel}">
<span>
<b class="v2-can-command-channel">${item.channel}</b>
<b class="v2-can-command-source" data-parse-source="direct_control">设备控件</b>
${escapeHtml(deviceName)}
</span>
<code>${escapeHtml(item.command)}</code>
</li>`).join("")}</ol>`;
validatedCommandsVisible = true;
requestAnimationFrame(syncSidebarLogBaseline);
}
function closeReasoningPicker() { function closeReasoningPicker() {
reasoningPickerControl?.close(); reasoningPickerControl?.close();
} }
@@ -620,7 +669,7 @@
function seatLevelControlValue(seat, feature) { function seatLevelControlValue(seat, feature) {
const text = seatFunctionValue(seat, feature); const text = seatFunctionValue(seat, feature);
const level = text.match(/L(\d+)/i)?.[1]; const level = text.match(/L(\d+)/i)?.[1];
return /OFF|关闭|--/.test(text) ? "off" : level || "1"; return /OFF|关闭|--/.test(text) ? "off" : level === "2" ? "low" : "high";
} }
function massageReadout() { function massageReadout() {
@@ -632,34 +681,31 @@
} }
const levelOptions = [ const levelOptions = [
["off", "关闭"], ["1", "1 档"], ["2", "2 档"], ["3", "3 档"], ["off", "关闭"], ["high", "1 档(高)"], ["low", "2 档(低)"],
]; ];
const toggleOptions = [["off", "关闭"], ["on", "开启"]]; const toggleOptions = [["off", "关闭"], ["on", "开启"]];
const massageModeOptions = [ const massageModeOptions = [
["off", "关闭"], ["relax", "舒缓"], ["stretch", "伸展"], ["waist", "腰部"], ["wave", "波浪"], ["off", "关闭"], ["relax", "放松"], ["stretch", "伸展"], ["slack", "松弛"], ["wave", "波浪"], ["pulse", "脉冲"],
]; ];
const massageStrengthOptions = [ const massageStrengthOptions = [
["off", "关闭"], ["low", "低"], ["medium", "中"], ["high", "高"], ["off", "关闭"], ["weak", "低"], ["medium", "中"], ["strong", "高"],
]; ];
const colorIndexNames = ["皓月白", "冰川蓝", "松石绿", "星云紫", "琥珀橙", "玫瑰红"]; const colorIndexNames = ["皓月白", "冰川蓝", "松石绿", "星云紫", "琥珀橙", "玫瑰红"];
const colorIndexOptions = Array.from({ length: 16 }, (_item, index) => [ const colorIndexOptions = Array.from({ length: 127 }, (_item, index) => [
String(index), colorIndexNames[index] ? `${index} · ${colorIndexNames[index]}` : `色号 ${index}`, String(index + 1), colorIndexNames[index] ? `${index + 1} · ${colorIndexNames[index]}` : `色号 ${index + 1}`,
]); ]);
const rangeControl = (key, label, value, min = 0, max = 100, step = 1, unit = "%", hint = "") => ({ const rangeControl = (key, label, value, min = 0, max = 100, step = 1, unit = "%", hint = "") => ({
key, label, type: "range", value: clampControlNumber(value, min, max), min, max, step, unit, hint, key, label, type: "range", value: clampControlNumber(value, min, max), min, max, step, unit, hint,
}); });
const selectControl = (key, label, value, options) => ({ key, label, type: "select", value, options }); const selectControl = (key, label, value, options) => ({ key, label, type: "select", value, options });
const hasControlValue = (values, key) => Object.hasOwn(values, key);
function deviceControlValue(deviceId, control) { function deviceControlValue(deviceId, control) {
const draft = displaySync.deviceDrafts[deviceId]; const draft = displaySync.deviceDrafts[deviceId];
return draft && Object.hasOwn(draft, control.key) ? draft[control.key] : control.value; return draft && Object.hasOwn(draft, control.key) ? draft[control.key] : control.value;
} }
function optionLabel(options, value) {
return options.find(([optionValue]) => String(optionValue) === String(value))?.[1] || String(value);
}
const syncedDevices = [ const syncedDevices = [
{ {
id: "passenger", name: "副驾座椅", icon: "armchair", id: "passenger", name: "副驾座椅", icon: "armchair",
@@ -670,7 +716,13 @@
rangeControl("backrest_recline", "靠背", seatAxisValue("passenger", "backrest_recline")), rangeControl("backrest_recline", "靠背", seatAxisValue("passenger", "backrest_recline")),
rangeControl("legrest_height", "腿托", seatAxisValue("passenger", "legrest_height")), rangeControl("legrest_height", "腿托", seatAxisValue("passenger", "legrest_height")),
], ],
command: (values) => `调节副驾座椅:前后${values.longitudinal}%,座椅高度${values.cushion_tilt}%,靠背${values.backrest_recline}%,腿托${values.legrest_height}%`, commands: (values) => [
["longitudinal", "horizontal"],
["cushion_tilt", "basin"],
["backrest_recline", "backrest"],
["legrest_height", "legrest"],
].filter(([key]) => hasControlValue(values, key))
.map(([key, axis]) => `seat passenger ${axis} position ${Math.round(Number(values[key]))}`),
}, },
{ {
id: "driver", name: "主驾座椅", icon: "armchair", id: "driver", name: "主驾座椅", icon: "armchair",
@@ -687,7 +739,22 @@
selectControl("massage_strength", "按摩强度", /关闭|--/.test(massageStrength) ? "off" : massageStrength.toLowerCase(), massageStrengthOptions), selectControl("massage_strength", "按摩强度", /关闭|--/.test(massageStrength) ? "off" : massageStrength.toLowerCase(), massageStrengthOptions),
]; ];
}, },
command: (values) => `调节主驾座椅:通风${optionLabel(levelOptions, values.ventilation)},加热${optionLabel(levelOptions, values.heating)},按摩模式${optionLabel(massageModeOptions, values.massage_mode)},按摩强度${optionLabel(massageStrengthOptions, values.massage_strength)}`, commands: (values) => {
const commands = [];
if (hasControlValue(values, "ventilation")) commands.push(`seat driver vent ${values.ventilation}`);
if (hasControlValue(values, "heating")) commands.push(`seat driver heat ${values.heating}`);
if (hasControlValue(values, "massage_mode")) {
commands.push(values.massage_mode === "off"
? "seat driver massage off"
: `seat driver massage mode ${values.massage_mode}`);
}
if (hasControlValue(values, "massage_strength")) {
commands.push(values.massage_strength === "off"
? "seat driver massage off"
: `seat driver massage strength ${values.massage_strength}`);
}
return [...new Set(commands)];
},
}, },
{ {
id: "windows", name: "车窗", icon: "gallery-vertical-end", id: "windows", name: "车窗", icon: "gallery-vertical-end",
@@ -698,19 +765,24 @@
rangeControl("rear_left", "左后窗", readText("#rearLeftWindow")), rangeControl("rear_left", "左后窗", readText("#rearLeftWindow")),
rangeControl("rear_right", "右后窗", readText("#rearRightWindow")), rangeControl("rear_right", "右后窗", readText("#rearRightWindow")),
], ],
command: (values) => `调节车窗开合比例:左前窗${values.front_left}%,右前窗${values.front_right}%,左后窗${values.rear_left}%,右后窗${values.rear_right}%`, commands: (values) => Object.entries({
front_left: "front-left", front_right: "front-right", rear_left: "rear-left", rear_right: "rear-right",
}).filter(([key]) => hasControlValue(values, key))
.map(([key, target]) => `window ${target} position ${Math.round(Number(values[key]))}`),
}, },
{ {
id: "shade", name: "遮阳帘", icon: "panels-top-left", id: "shade", name: "遮阳帘", icon: "panels-top-left",
rows: () => [["当前位置", readText("#shadePositionValue")]], rows: () => [["当前位置", readText("#shadePositionValue")]],
controls: () => [rangeControl("position", "当前位置", readText("#shadePositionValue"), 0, 100, 1, "%", "0% 关闭 · 100% 打开")], controls: () => [rangeControl("position", "当前位置", readText("#shadePositionValue"), 0, 100, 1, "%", "0% 关闭 · 100% 打开")],
command: (values) => `将遮阳帘开合位置调整为${values.position}%`, commands: (values) => hasControlValue(values, "position")
? [`shade position ${Math.round(Number(values.position))}`]
: [],
}, },
{ {
id: "reading", name: "阅读灯", icon: "book-open", id: "reading", name: "阅读灯", icon: "book-open",
rows: () => [["开关状态", readText("#interiorLightPower", "等待反馈")]], rows: () => [["开关状态", readText("#interiorLightPower", "等待反馈")]],
controls: () => [selectControl("power", "开关状态", /开启|ON/.test(readText("#interiorLightPower")) ? "on" : "off", toggleOptions)], controls: () => [selectControl("power", "开关状态", /开启|ON/.test(readText("#interiorLightPower")) ? "on" : "off", toggleOptions)],
command: (values) => `${values.power === "on" ? "打开" : "关闭"}阅读灯`, commands: (values) => hasControlValue(values, "power") ? [`light reading all ${values.power}`] : [],
}, },
{ {
id: "ambient", name: "氛围灯", icon: "sparkles", id: "ambient", name: "氛围灯", icon: "sparkles",
@@ -724,31 +796,39 @@
const [frontColor, rearColor] = splitReadout("#ambientColorIndex"); const [frontColor, rearColor] = splitReadout("#ambientColorIndex");
return [ return [
selectControl("power", "开关状态", /OFF|关闭/.test(readText("#ambientPower")) ? "off" : "on", toggleOptions), selectControl("power", "开关状态", /OFF|关闭/.test(readText("#ambientPower")) ? "off" : "on", toggleOptions),
rangeControl("front_brightness", "前排亮度", frontBrightness, 0, 5, 1, " 级"), rangeControl("brightness", "全车亮度", frontBrightness === "-" ? rearBrightness : frontBrightness, 1, 5, 1, " 级"),
selectControl("front_color", "前排颜色", String(clampControlNumber(frontColor, 0, 15, 0)), colorIndexOptions), selectControl("color", "全车颜色", String(clampControlNumber(frontColor === "-" ? rearColor : frontColor, 1, 127, 1)), colorIndexOptions),
rangeControl("rear_brightness", "后排亮度", rearBrightness, 0, 5, 1, " 级"),
selectControl("rear_color", "后排颜色", String(clampControlNumber(rearColor, 0, 15, 0)), colorIndexOptions),
]; ];
}, },
command: (values) => `调节氛围灯:${values.power === "on" ? "开启" : "关闭"},前排亮度${values.front_brightness}级、颜色${optionLabel(colorIndexOptions, values.front_color)},后排亮度${values.rear_brightness}级、颜色${optionLabel(colorIndexOptions, values.rear_color)}`, commands: (values) => [
...(hasControlValue(values, "power") ? [`light ambient ${values.power}`] : []),
...(hasControlValue(values, "brightness") ? [`light ambient brightness ${Math.round(Number(values.brightness))}`] : []),
...(hasControlValue(values, "color") ? [`light ambient color ${Math.round(Number(values.color))}`] : []),
],
}, },
{ {
id: "headlight", name: "前大灯", icon: "car-front", id: "headlight", name: "前大灯", icon: "car-front",
controllable: false,
rows: () => [["近光灯", readText("#lowBeamState")], ["远光灯", readText("#highBeamState")], ["雾灯", readText("#fogLightState")], ["转向灯", readText("#turnSignalState")]], rows: () => [["近光灯", readText("#lowBeamState")], ["远光灯", readText("#highBeamState")], ["雾灯", readText("#fogLightState")], ["转向灯", readText("#turnSignalState")]],
controls: () => [["low_beam", "近光灯", "#lowBeamState"], ["high_beam", "远光灯", "#highBeamState"], ["fog_light", "雾灯", "#fogLightState"], ["turn_signal", "转向灯", "#turnSignalState"]].map(([key, label, selector]) => selectControl(key, label, /开启|ON/.test(readText(selector)) ? "on" : "off", toggleOptions)), controls: () => [["low_beam", "近光灯", "#lowBeamState"], ["high_beam", "远光灯", "#highBeamState"], ["fog_light", "雾灯", "#fogLightState"], ["turn_signal", "转向灯", "#turnSignalState"]].map(([key, label, selector]) => selectControl(key, label, /开启|ON/.test(readText(selector)) ? "on" : "off", toggleOptions)),
command: (values) => `调节前大灯:近光灯${optionLabel(toggleOptions, values.low_beam)},远光灯${optionLabel(toggleOptions, values.high_beam)},雾灯${optionLabel(toggleOptions, values.fog_light)},转向灯${optionLabel(toggleOptions, values.turn_signal)}`,
}, },
{ {
id: "climate", name: "空调", icon: "fan", id: "climate", name: "空调", icon: "fan",
rows: () => [["开关状态", readText("#climatePower", "等待反馈")], ["主驾温度", readText("#driverTemperature")], ["副驾温度", readText("#passengerTemperature")], ["风量", readText("#fanLevel")], ["循环模式", readText("#cycleMode")]], rows: () => [["开关状态", readText("#climatePower", "等待反馈")], ["主驾温度", readText("#driverTemperature")], ["副驾温度", readText("#passengerTemperature")], ["风量", readText("#fanLevel")], ["循环模式", readText("#cycleMode")]],
controls: () => [ controls: () => [
selectControl("power", "开关状态", /ON|ACTIVE|开启/.test(readText("#climatePower")) ? "on" : "off", toggleOptions), selectControl("power", "开关状态", /ON|ACTIVE|开启/.test(readText("#climatePower")) ? "on" : "off", toggleOptions),
rangeControl("driver_temperature", "主驾温度", readText("#driverTemperature"), 16, 32, 0.5, "℃"), rangeControl("driver_temperature", "主驾温度", Math.round(clampControlNumber(readText("#driverTemperature"), 18, 32, 24)), 18, 32, 1, "℃"),
rangeControl("passenger_temperature", "副驾温度", readText("#passengerTemperature"), 16, 32, 0.5, "℃"), rangeControl("passenger_temperature", "副驾温度", Math.round(clampControlNumber(readText("#passengerTemperature"), 18, 32, 24)), 18, 32, 1, "℃"),
rangeControl("fan_level", "风量", readText("#fanLevel"), 0, 7, 1, " 档"), rangeControl("fan_level", "风量", readText("#fanLevel"), 1, 7, 1, " 档"),
selectControl("cycle_mode", "循环模式", readText("#cycleMode").includes("内") ? "internal" : "external", [["internal", "内循环"], ["external", "外循环"]]), selectControl("cycle_mode", "循环模式", readText("#cycleMode").includes("内") ? "internal" : "external", [["internal", "内循环"], ["external", "外循环"]]),
], ],
command: (values) => `调节空调:${values.power === "on" ? "开启" : "关闭"},主驾${values.driver_temperature}℃,副驾${values.passenger_temperature}℃,风量${values.fan_level}档,${values.cycle_mode === "internal" ? "内循环" : "外循环"}`, commands: (values) => [
...(hasControlValue(values, "power") ? [`ac ${values.power}`] : []),
...(hasControlValue(values, "driver_temperature") ? [`ac temp driver ${Math.round(Number(values.driver_temperature))}`] : []),
...(hasControlValue(values, "passenger_temperature") ? [`ac temp passenger ${Math.round(Number(values.passenger_temperature))}`] : []),
...(hasControlValue(values, "fan_level") ? [`ac fan ${Math.round(Number(values.fan_level))}`] : []),
...(hasControlValue(values, "cycle_mode") ? [`ac cycle ${values.cycle_mode}`] : []),
],
}, },
]; ];
@@ -771,7 +851,8 @@
grid.innerHTML = syncedDevices.map((device) => { grid.innerHTML = syncedDevices.map((device) => {
const rows = device.rows(); const rows = device.rows();
const waiting = rows.some(([, value]) => /--|等待|同步中|未知/.test(value)); const waiting = rows.some(([, value]) => /--|等待|同步中|未知/.test(value));
const controlOpen = displaySync.controlDevice === device.id; const controllable = device.controllable !== false;
const controlOpen = controllable && displaySync.controlDevice === device.id;
const hasDraft = Boolean(displaySync.deviceDrafts[device.id]); const hasDraft = Boolean(displaySync.deviceDrafts[device.id]);
const pending = Boolean(displaySync.pendingControls[device.id]); const pending = Boolean(displaySync.pendingControls[device.id]);
const stateLabel = pending ? "已发送 · 等待同步" : hasDraft ? "参数待发送" : `${rows.length} 项关键状态`; const stateLabel = pending ? "已发送 · 等待同步" : hasDraft ? "参数待发送" : `${rows.length} 项关键状态`;
@@ -780,13 +861,13 @@
<button class="v2-device-card-focus" type="button" data-device-focus="${device.id}" aria-label="在孪生界面查看${device.name}"> <button class="v2-device-card-focus" type="button" data-device-focus="${device.id}" aria-label="在孪生界面查看${device.name}">
<span><i data-lucide="${device.icon}"></i></span><span><strong>${device.name}</strong><small data-device-card-state>${stateLabel}</small></span><i class="v2-device-signal"></i> <span><i data-lucide="${device.icon}"></i></span><span><strong>${device.name}</strong><small data-device-card-state>${stateLabel}</small></span><i class="v2-device-signal"></i>
</button> </button>
<button class="v2-device-adjust-toggle" type="button" data-device-control-toggle="${device.id}" aria-expanded="${String(controlOpen)}"><i data-lucide="sliders-horizontal"></i><span>${controlOpen ? "收起" : "调节"}</span></button> <button class="v2-device-adjust-toggle" type="button" data-device-control-toggle="${device.id}" aria-expanded="${String(controlOpen)}"${controllable ? "" : " disabled"}><i data-lucide="${controllable ? "sliders-horizontal" : "eye"}"></i><span>${controllable ? (controlOpen ? "收起" : "调节") : "只读"}</span></button>
</header> </header>
<dl>${rows.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}</dl> <dl>${rows.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}</dl>
<div class="v2-device-controls" data-device-controls="${device.id}"${controlOpen ? "" : " hidden"}> <div class="v2-device-controls" data-device-controls="${device.id}"${controlOpen ? "" : " hidden"}>
<div class="v2-device-controls-intro"><span>手动调节</span><small>参数确认后通过编排链路发送至车机</small></div> <div class="v2-device-controls-intro"><span>手动调节</span><small>参数确认后通过编排链路发送至车机</small></div>
${device.controls().map((control) => renderDeviceControl(device, control)).join("")} ${device.controls().map((control) => renderDeviceControl(device, control)).join("")}
<button class="v2-device-control-apply" type="button" data-device-control-apply="${device.id}"${pending ? " disabled" : ""}><i data-lucide="send"></i><span>${pending ? "等待车机同步" : "发送控制"}</span></button> <button class="v2-device-control-apply" type="button" data-device-control-apply="${device.id}"${pending || !hasDraft ? " disabled" : ""}><i data-lucide="send"></i><span>${pending ? "等待车机同步" : hasDraft ? "发送 CAN 控制" : "请先修改参数"}</span></button>
</div> </div>
</article>`; </article>`;
}).join(""); }).join("");
@@ -817,28 +898,68 @@
})); }));
} }
function sendDeviceControl(deviceId) { async function sendDeviceControl(deviceId) {
const device = syncedDevices.find((item) => item.id === deviceId); const device = syncedDevices.find((item) => item.id === deviceId);
if (!device) return; if (!device) return;
const values = Object.fromEntries(device.controls().map((control) => [control.key, String(deviceControlValue(device.id, control))])); if (device.controllable === false || typeof device.commands !== "function") {
const utterance = device.command(values); window.CognitiveAgentConsole?.notify?.(`${device.name}当前只有状态反馈,尚无已验证的 CAN 控制白名单`);
return;
}
const draft = displaySync.deviceDrafts[device.id];
if (!draft || !Object.keys(draft).length) {
window.CognitiveAgentConsole?.notify?.("请先修改需要发送的参数");
return;
}
if (displaySync.pendingControls[device.id]) {
window.CognitiveAgentConsole?.notify?.("该设备的上一组 CAN 指令仍在执行");
return;
}
const values = Object.fromEntries(Object.entries(draft).map(([key, value]) => [key, String(value)]));
const commands = device.commands(values).filter(Boolean);
if (!commands.length) {
window.CognitiveAgentConsole?.notify?.("修改项没有对应的 CAN 白名单控制指令");
return;
}
const commandSummary = commands.join(";");
const detail = { const detail = {
device_id: device.id, device_id: device.id,
device_name: device.name, device_name: device.name,
controls: values, controls: values,
utterance, can_commands: commands,
source: "web_console", source: "web_console",
requested_at: new Date().toISOString(), requested_at: new Date().toISOString(),
}; };
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-control-request", { detail })); const execute = window.CognitiveAgentConsole?.executeCanCommands;
const accepted = window.CognitiveAgentConsole?.executeUtterance?.(utterance, { autoSubmit: true }) || false; if (typeof execute !== "function") {
if (!accepted) { window.CognitiveAgentConsole?.notify?.("当前页面尚未加载 CAN 直控接口");
window.CognitiveAgentConsole?.notify?.("上一条指令仍在执行,请稍候");
return; return;
} }
displaySync.pendingControls[device.id] = utterance; window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-can-control-request", { detail }));
appendSyncLog({ category: "device", title: `网页端调节${device.name}`, detail: utterance, status: "info" }); displaySync.pendingControls[device.id] = commandSummary;
appendSyncLog({ category: "device", title: `网页端直控${device.name}`, detail: commandSummary, status: "info" });
renderDirectCanCommands(commands, device.name);
renderSyncedDevices(); renderSyncedDevices();
try {
await execute(commands, { deviceName: device.name });
delete displaySync.deviceDrafts[device.id];
appendSyncLog({
category: "device",
title: `${device.name} CAN 控制已执行`,
detail: commandSummary,
status: "success",
});
} catch (error) {
appendSyncLog({
category: "device",
title: `${device.name} CAN 控制发送失败`,
detail: error?.message || "CAN 控制请求失败",
status: "error",
});
window.CognitiveAgentConsole?.notify?.(error?.message || "CAN 控制请求失败");
} finally {
delete displaySync.pendingControls[device.id];
renderSyncedDevices();
}
} }
function scheduleSyncedDeviceRender() { function scheduleSyncedDeviceRender() {
@@ -1389,19 +1510,6 @@
}); });
}); });
window.addEventListener("cognitive-agent:request-complete", (event) => { window.addEventListener("cognitive-agent:request-complete", (event) => {
const pendingDevice = Object.entries(displaySync.pendingControls).find(([, utterance]) => utterance === event.detail?.utterance)?.[0];
if (pendingDevice) {
const device = syncedDevices.find((item) => item.id === pendingDevice);
delete displaySync.pendingControls[pendingDevice];
if (!event.detail?.error) delete displaySync.deviceDrafts[pendingDevice];
appendSyncLog({
category: "device",
title: event.detail?.error ? `${device?.name || "设备"}控制发送失败` : `${device?.name || "设备"}控制已执行`,
detail: event.detail?.error?.message || "控制结果已进入车机状态同步链路",
status: event.detail?.error ? "error" : "success",
});
renderSyncedDevices();
}
if (event.detail?.error && !validatedCommandsVisible) { if (event.detail?.error && !validatedCommandsVisible) {
renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" }); renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" });
} }
+1 -1
View File
@@ -718,7 +718,7 @@
} }
function isPhysicalVehicleSource(value) { function isPhysicalVehicleSource(value) {
return ["physical_can", "physical_can_ch0", "physical_can_ch1", "physical_partial"].includes(value); return ["physical_can", "physical_can_ch0", "physical_can_ch1", "wireless_mqtt_ch0", "wireless_mqtt_ch1", "physical_partial"].includes(value);
} }
function containsAuthoritativePhysicalCh1State(value) { function containsAuthoritativePhysicalCh1State(value) {
@@ -14,7 +14,7 @@
"parameters": {"type": "object"}, "parameters": {"type": "object"},
"parse_source": { "parse_source": {
"type": "string", "type": "string",
"enum": ["local_rule", "cloud_model", "cloud_supplement"] "enum": ["local_rule", "cloud_model", "cloud_supplement", "direct_control"]
}, },
"motion": { "motion": {
"type": "object", "type": "object",
+77
View File
@@ -0,0 +1,77 @@
# CAN0 副驾座椅控制指令白名单
- 适用项目:`Cognitive_Agent`
- 适用分支:`feature/N7Web`
- 核对日期:2026-08-25
- 控制对象:CAN0 右前/副驾座椅
- 协议依据:`passenger_seat_firmware/firmware` 与 `Passenger_Seat_Controller_demo` 中共同存在的经典 CAN 协议
- 验证层级:固件源码、语义解析、参数门禁和离线编译;本次没有向真实 CAN 总线发送报文
CAN0 与 CAN1 必须分开解释。本文中的 `seat passenger ...` 只生成副驾座椅操作,不得改写为主驾 `seat driver ...`,也不得发往 CAN1。所有 CAN0 原始帧均为标准 11 位 ID、Classic CAN、DLC 8、无 BRS。
## 有效语义控制指令
| 类别 | 规范指令 | 参数约束 | 当前执行逻辑 |
| --- | --- | --- | --- |
| 水平绝对位置 | `seat passenger horizontal position <0-100>` | 整数百分比 | 有线:`0x300`,一般位置使用 Opcode `0x01`;显式 0% 使用安全归零 Opcode `0x0B`。无线:转换为 seat-ota `0x6A0` MOVE,并以新鲜 `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 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 |
为了兼容现有页面,精确指令入口仍接受以下旧别名,但前端“已解析 CAN 控制指令”统一显示上表规范形式:
- `longitudinal` → `horizontal`
- `height` → `basin`
- `seat passenger dynamic start rocking-horse` → `seat passenger dynamic rocking-horse`
- `seat passenger dynamic start cradle` → `seat passenger dynamic cradle`
## 原生 0x300 控制字段
| Byte | 字段 | 说明 |
| ---: | --- | --- |
| 0 | Opcode | `0x01` 绝对、`0x02` 固件原生相对、`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 |
当前自然语言相对移动采用“读取当前位置→计算目标→闭环执行”的安全策略,不直接无条件暴露 Opcode `0x02`。这是执行策略与语义命令的区别:用户仍能看到相对语义,底层可根据位置是否有效选择更安全的原始帧序列。
## CAN0 状态反馈与前端映射
| 反馈 ID | 固件含义 | 后端/前端映射 |
| --- | --- | --- |
| `0x301` | 系统状态、ACK、拒绝原因、有效轴掩码 | 作为命令 ACK 和安全诊断,不伪装成位置 |
| `0x302` | 单轴任务、方向、位置有效位、故障、归一化位置 | Motor 0→前后 `longitudinal`;1→高度 `cushion_tilt`;2→靠背 `backrest_recline`;3→腿托 `legrest_height`;`Value_X100 / 100` 显示为 0–100% |
| `0x303` | Hall、量程、速度、最近电机事件 | 诊断缓存,不覆盖 `0x302` 的权威位置 |
| `0x304` | 单轴电流与堵转诊断 | 诊断缓存 |
| `0x305` | 风扇/加热电流诊断 | 诊断缓存 |
| `0x306` | 木马/摇篮模式、阶段、周期、故障掩码 | `passenger.dynamic_mode`,前端显示运行/空闲/故障 |
| `0x4FA` | 副驾通风和加热状态 | `passenger.ventilation`、`passenger.heating` |
| `0x6A0/0x6A2` | 旧车机 seat-ota 查询/应答 | 保留兼容;位置查询应答转换为同一内部 `0x302` 状态,不与原生状态重复解释 |
无线状态来自 `n7/n7-headunit/can/state` 时,以上字段使用 `wireless_mqtt_ch0` 标记;有线台架使用 `physical_can`。前端只把这些物理来源显示为实车反馈。
## 自然语言示例
- “把副驾座椅前后位置调到 35%” → `seat passenger horizontal position 35`
- “副驾坐盆向上调 10%” → `seat passenger basin relative up 10`
- “副驾靠背向后移动 15%,空调设为 24 度” → CAN0 `seat passenger backrest relative backward 15` + CAN1 `ac temp driver 24`
- “开启木马模式并打开氛围灯” → CAN0 `seat passenger dynamic rocking-horse` + CAN1 `light ambient on`
- “关闭摇篮模式,风量调到 2 档,打开遮阳帘” → CAN0 `seat passenger dynamic stop` + CAN1 `ac fan 2` + `shade open`
## 安全边界
- 百分比越界、非法方向、未知轴和不在枚举内的动态模式会在生成 CAN 帧前拒绝。
- 相对移动缺少有效 CAN0 当前位置时拒绝,不使用虚构位置计算目标。
- MQTT Broker 接受消息只代表已提交;车辆动作是否完成仍以 CAN0 状态反馈为准。
- 固件还实现校准、清故障、查询、停止单电机等维护 Opcode,但它们不属于面向自然语言和前端设备控件的控制白名单。
+1 -1
View File
@@ -25,7 +25,7 @@
| 主驾座椅通风/加热 | `seat driver <vent\|heat> <off\|low\|high>` | `front-left` 可作为主驾输入兼容别名,规范输出仍为 `driver` | `0x431` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` | | 主驾座椅通风/加热 | `seat driver <vent\|heat> <off\|low\|high>` | `front-left` 可作为主驾输入兼容别名,规范输出仍为 `driver` | `0x431` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅按摩关闭 | `seat driver massage off` | 仅关闭动作 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` | | 主驾座椅按摩关闭 | `seat driver massage off` | 仅关闭动作 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅按摩强度 | `seat driver massage strength <weak\|medium\|strong>` | `1\|2\|3` 也可作为输入兼容别名,规范输出为单词枚举 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` | | 主驾座椅按摩强度 | `seat driver massage strength <weak\|medium\|strong>` | `1\|2\|3` 也可作为输入兼容别名,规范输出为单词枚举 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅按摩模式 | `seat driver massage mode <wave\|pulse\|stretch\|slack\|relax>` | 仅接受列出的 5 种模式 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` | | 主驾座椅按摩模式 | `seat driver massage mode <wave\|pulse\|stretch\|slack\|relax>` | 仅接受列出的 5 种模式;自然语言“打开主驾座椅按摩”使用已验证的 `relax` 模式启动 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅水平调节 | `seat driver horizontal <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` | | 主驾座椅水平调节 | `seat driver horizontal <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
| 主驾座椅高度调节 | `seat driver height <up\|down> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` | | 主驾座椅高度调节 | `seat driver height <up\|down> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
| 主驾座椅靠背调节 | `seat driver backrest <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` | | 主驾座椅靠背调节 | `seat driver backrest <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
@@ -0,0 +1 @@
,orin,ubuntu,25.08.2026 09:37,file:///home/orin/.config/libreoffice/4;
@@ -0,0 +1,111 @@
# 自然语言转 CAN 控制指令测试报告
## 1. 测试结论
| 项目 | 结果 |
| --- | ---: |
| 测试日期 | 2026-08-25 |
| pytest 测试入口 | 2 |
| 生成式组合用例 | 1133 |
| 定向 CAN0/CAN1 组合用例 | 6 |
| 实际执行子用例 | **1139** |
| 通过 | **1139** |
| 失败 | **0** |
| 跳过 | **0** |
| 通过率 | **100%** |
| 执行耗时 | 35.42 s |
**结论:**当前本地规则能够将本次覆盖的自然语言控制指令正确解析为 CAN0/CAN1 语义控制指令;跨通道组合中,副驾座椅指令保持在 CAN0,主驾座椅、空调、灯光、车窗、门锁和遮阳帘等指令保持在 CAN1。白名单外指令未生成 CAN 控制流。
## 2. 测试方式
- 解析器:本地规则(`provider=local`)
- 执行模式:`dry_run`
- 硬件执行:关闭
- 真实 CAN 报文下发:未进行
- 测试入口:`tests/integration/test_natural_can_command_combinations.py`
- 机器可执行用例:`tests/fixtures/natural_can_command_combination_suite.json`
- 完整逐条用例:`docs/testing/自然语言转CAN组合测试用例.csv`
执行命令:
```bash
PYTHONPATH=src:domains/seat/src .venv/bin/python -m pytest -q tests/integration/test_natural_can_command_combinations.py
```
原始结果:
```text
2 passed, 2 warnings, 1139 subtests passed in 35.42s
```
2 个警告均为 `jsonschema.RefResolver` 弃用提示,不影响本次解析结果。
## 3. 1133 条生成式用例分布
### 3.1 按组合类型
| 用例类型 | 数量 | 说明 |
| --- | ---: | --- |
| atomic | 177 | 单条原子指令 |
| pairwise | 300 | 两两组合 |
| pairwise_separator_variant | 300 | 两两组合的不同分隔方式 |
| three_command | 84 | 三指令组合 |
| four_command | 126 | 四指令组合 |
| cross_can0_can1 | 120 | CAN0/CAN1 跨通道组合 |
| multi_command_scene | 10 | 常用多指令场景 |
| safety_rejection | 16 | 白名单外和不安全输入拒绝 |
| **合计** | **1133** | |
### 3.2 按 CAN 通道
| 预期通道 | 数量 | 说明 |
| --- | ---: | --- |
| CAN0 | 13 | 副驾座椅单通道控制 |
| CAN1 | 899 | 车身、空调、主驾座椅、灯光等控制 |
| CAN0 + CAN1 | 205 | 副驾座椅与车身功能组合控制 |
| none | 16 | 安全拒绝,不应生成控制指令 |
| **合计** | **1133** | |
## 4. 代表性解析结果
| 自然语言输入 | 通道 | 预期 CAN 语义控制指令/操作 | 结果 |
| --- | --- | --- | --- |
| 后排车门上锁 | CAN1 | `door rear lock` | 通过 |
| 打开左前车窗 | CAN1 | `window front-left open` | 通过 |
| 副驾驶座椅加热开到2档 | CAN0 | `seat.set_heating(state=on, level=2)` | 通过 |
| 后排车门上锁,打开左前车窗,副驾驶座椅加热开到2档 | CAN0 + CAN1 | CAN1: `door rear lock`; CAN1: `window front-left open`; CAN0: `seat.set_heating(state=on, level=2)` | 通过 |
| 开启木马模式并打开氛围灯 | CAN0 + CAN1 | CAN0: `seat.start_dynamic_mode(mode_id=1)`; CAN1: `light ambient on` | 通过 |
| 开启动态摇篮模式,空调24度,打开遮阳帘 | CAN0 + CAN1 | CAN0: `seat.start_dynamic_mode(mode_id=2)`; CAN1: `ac temp driver 24`; CAN1: `shade open` | 通过 |
| 副驾座椅前后调到20%、坐盆调到30%、靠背调到40%、腿托调到50%,空调风量3档,关闭全部车窗 | CAN0 + CAN1 | CAN0: 四轴绝对位置 20/30/40/50; CAN1: `ac fan 3`; CAN1: `window all close` | 通过 |
| 副驾靠背向后移动15%,主驾温度设为25度,遮阳帘打开 | CAN0 + CAN1 | CAN0: 靠背相对后移 15%; CAN1: `ac temp driver 25`; CAN1: `shade open` | 通过 |
| 主驾座椅通风4档 | none | 白名单外,不生成 CAN 指令 | 通过 |
| 主驾进入零重力模式 | none | 未验证能力,不生成 CAN 指令 | 通过 |
## 5. 6 条新增定向 CAN0/CAN1 用例
| # | 自然语言 | CAN0 结果 | CAN1 结果 |
| ---: | --- | --- | --- |
| 1 | 把副驾座椅水平位置调到35%,空调温度设置为24度,打开氛围灯 | 水平绝对位置 35% | `ac temp driver 24`; `light ambient on` |
| 2 | 副驾坐盆向上调整10%,关闭后排车窗,空调风量2档 | 坐盆相对上调 10% | `window rear close`; `ac fan 2` |
| 3 | 开启木马模式并打开氛围灯 | 木马模式 | `light ambient on` |
| 4 | 开启动态摇篮模式,空调24度,打开遮阳帘 | 摇篮模式 | `ac temp driver 24`; `shade open` |
| 5 | 副驾座椅前后调到20%、坐盆调到30%、靠背调到40%、腿托调到50%,空调风量3档,关闭全部车窗 | 四轴绝对位置 | `ac fan 3`; `window all close` |
| 6 | 副驾靠背向后移动15%,主驾温度设为25度,遮阳帘打开 | 靠背相对后移 15% | `ac temp driver 25`; `shade open` |
以上 6 条用例全部通过。
## 6. 覆盖能力
- CAN0:副驾座椅加热、通风、绝对位置、相对位置、木马模式和摇篮模式。
- CAN1:门锁、车窗、空调、风口、主驾座椅、室内灯、阅读灯、氛围灯和遮阳帘。
- 组合解析:两指令、三指令、四指令、场景化多指令以及 CAN0/CAN1 跨通道组合。
- 位置语义:主驾、副驾、左前、右前、左后、右后、前排、后排。
- 安全约束:越界档位、未验证能力和白名单外目标不生成 CAN 控制流。
## 7. 范围和限制
1. 本报告验证的是“自然语言 → CAN 语义控制指令/操作”解析层。
2. 本次使用 `dry_run`,未向真实 CAN0/CAN1 总线或 MQTT Broker 下发报文。
3. 通过本报告不等于实车执行器、USB-CAN 适配器、MQTT 转发和 ECU 物理动作已完成端到端验证。
4. 如需验证真实执行,应单独进行有线 CAN 和无线 MQTT 台架测试,并记录原始报文、状态回馈和物理动作。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
# 自然语言转 CAN 控制指令组合测试说明
- 生成日期:2026-08-24
- 总用例数:**1133**
- 执行方式:本地规则、`dry_run`,不发送真实 CAN 帧
- 完整逐条清单:`docs/testing/自然语言转CAN组合测试用例.csv`
- 机器可执行数据:`tests/fixtures/natural_can_command_combination_suite.json`
## 覆盖数量
| 用例类型 | 数量 |
| --- | ---: |
| `atomic` | 177 |
| `cross_can0_can1` | 120 |
| `four_command` | 126 |
| `multi_command_scene` | 10 |
| `pairwise` | 300 |
| `pairwise_separator_variant` | 300 |
| `safety_rejection` | 16 |
| `three_command` | 84 |
| 通道 | 数量 |
| --- | ---: |
| `can0` | 13 |
| `can0+can1` | 205 |
| `can1` | 899 |
| `none` | 16 |
## 组合策略
1. 原子用例覆盖 CAN1 门锁、车窗、空调、风口、主驾座椅、灯光、遮阳帘,以及 CAN0 副驾座椅。
2. 两两组合从 25 个互不冲突的控制槽中取组合,同时覆盖中文逗号和分号分隔形式。
3. 三指令和四指令组合覆盖 CAN0/CAN1、座椅、空调、灯光、车窗、门锁、风口与遮阳帘的交叉。
4. 跨通道组合明确检查副驾座椅留在 CAN0、主驾和车身控制留在 CAN1。
5. 多指令场景覆盖观影、双座椅、全温区空调、泊车关闭、车窗与遮阳帘等常用场景。
6. 安全用例要求白名单外目标、档位、能力和矛盾指令不生成 CAN 控制流。
## 执行
```bash
PYTHONPATH=src:domains/seat/src .venv/bin/python -m pytest -q tests/integration/test_natural_can_command_combinations.py
```
重新生成清单:
```bash
.venv/bin/python tools/generate_natural_can_command_suite.py
```
@@ -378,6 +378,9 @@ class HardwareSeatExecutor:
elif frame.arbitration_id == 0x4FA and len(frame.data) >= 8: elif frame.arbitration_id == 0x4FA and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x4FA, frame.data) values = self.matrix.decode_by_frame_id(0x4FA, frame.data)
self._record_thermal_state(state, values, frame) self._record_thermal_state(state, values, frame)
elif frame.arbitration_id == 0x306 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x306, frame.data)
self._record_dynamic_state(state, values, frame)
state["captured_at"] = _now() state["captured_at"] = _now()
state["source"] = "physical_can" state["source"] = "physical_can"
state["target"] = { state["target"] = {
@@ -461,6 +464,30 @@ class HardwareSeatExecutor:
self._set_thermal_feature(state, feature, raw) self._set_thermal_feature(state, feature, raw)
state[feature]["feedback_frame"] = frame.public() state[feature]["feedback_frame"] = frame.public()
@staticmethod
def _record_dynamic_state(
state: dict[str, Any],
values: dict[str, int],
frame: CanFrame,
) -> None:
mode_id = int(values["Dynamic_Mode_ID"])
dynamic_state = int(values["Dynamic_State"])
names = {1: "木马模式", 2: "摇篮模式"}
states = {0: "idle", 1: "entry", 2: "running", 3: "fault"}
state["dynamic_mode"] = {
"mode_id": mode_id,
"name": names.get(mode_id, ""),
"state": states.get(dynamic_state, "unknown"),
"phase": int(values["Dynamic_Phase"]),
"active_mask": int(values["Dynamic_Active_Mask"]),
"fault_mask": int(values["Dynamic_Fault_Mask"]),
"cycle_index": int(values["Dynamic_Cycle_Index"]),
"phase_elapsed_ms": int(values["Dynamic_Phase_Elapsed_Ms"]),
"repeat": "bounded_runtime" if dynamic_state in {1, 2} else "none",
"max_runtime_ms": 600_000,
"feedback_frame": frame.public(),
}
@staticmethod @staticmethod
def _set_thermal_feature( def _set_thermal_feature(
state: dict[str, Any], feature: str, raw: int state: dict[str, Any], feature: str, raw: int
+186 -12
View File
@@ -20,28 +20,55 @@ STATE_PATTERN = re.compile(
) )
LEVEL_PATTERN = re.compile(r"(最高|最低|[1-5一二三四五高中低])\s*档") LEVEL_PATTERN = re.compile(r"(最高|最低|[1-5一二三四五高中低])\s*档")
POSITION_PATTERN_TEMPLATE = ( POSITION_PATTERN_TEMPLATE = (
r"{alias}(?:位置)?(?:调到|移动到|移到|设为|设置为|调至|移动至|到|为)?" r"{alias}(?:位置)?(?:调到|调至|移动到|移动至|移到|移至|"
r"转动到|转动至|旋转到|旋转至|调节到|调节至|调整到|调整至|"
r"设为|设置为|设置到|设置至|设置|设|到|为)?"
r"(?P<position>[+-]?\d{{1,3}}(?:\.\d+)?)%" r"(?P<position>[+-]?\d{{1,3}}(?:\.\d+)?)%"
) )
ANGLE_PATTERN_TEMPLATE = ( ANGLE_PATTERN_TEMPLATE = (
r"{alias}(?:夹角|角度)?(?:调到|放倒到|抬到|移动到|移到|设为|设置为|" r"{alias}(?:夹角|角度)?(?:调到|调至|放倒到|抬到|移动到|移动至|移到|移至|"
r"调至|到|为)?(?P<angle>\d{{2,3}})(?:度|°)" r"转动到|转动至|旋转到|旋转至|调节到|调节至|调整到|调整至|"
r"设为|设置为|设置到|设置至|设置|设|到|为)?"
r"(?P<angle>\d{{2,3}})(?:度|°)"
) )
RELATIVE_ANGLE_PATTERN_TEMPLATE = ( RELATIVE_ANGLE_PATTERN_TEMPLATE = (
r"{alias}(?:再|继续|接着|然后)?(?P<direction>向前|往前|朝前|前倾|向后|往后|朝后|后仰|" r"{alias}(?:再|继续|接着|然后)?"
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
r"(?:再|继续|接着|然后)?"
r"(?P<direction>向前|往前|朝前|前倾|向后|往后|朝后|后仰|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)" r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})(?:度|°)" r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
r"(?P<delta>\d{{1,3}})(?:度|°)"
) )
RELATIVE_PERCENT_PATTERN_TEMPLATE = ( RELATIVE_PERCENT_PATTERN_TEMPLATE = (
r"{alias}(?:再|继续|接着|然后)?(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|" r"{alias}(?:再|继续|接着|然后)?"
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
r"(?:再|继续|接着|然后)?"
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)" r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})%" r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
r"(?P<delta>\d{{1,3}})%"
)
DIRECTIONAL_ABSOLUTE_PERCENT_PATTERN = re.compile(
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)*"
r"(?:到|至|为)"
r"(?P<position>[+-]?\d{1,3}(?:\.\d+)?)%"
)
DIRECTIONAL_RELATIVE_PERCENT_PATTERN = re.compile(
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
r"(?P<delta>\d{1,3})%"
) )
TIMED_MOTION_PATTERN_TEMPLATE = ( TIMED_MOTION_PATTERN_TEMPLATE = (
r"{alias}(?:持续|保持)?(?:再|继续|接着|然后)?" r"{alias}(?:持续|保持)?(?:再|继续|接着|然后)?"
r"(?:移动|移|转动|旋转|转|调节|调整|调|倾斜|抬|升|降)?"
r"(?:再|继续|接着|然后)?"
r"(?P<direction>向前|往前|朝前|前移|前倾|向后|往后|朝后|后移|后仰|" r"(?P<direction>向前|往前|朝前|前移|前倾|向后|往后|朝后|后移|后仰|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)" r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降|运行|运动|持续|保持|时长)*" r"(?:移动|移|转动|旋转|转|调节|调整|调|倾斜|抬|升|降|运行|运动|持续|保持|时长)*"
r"(?P<duration>\d+(?:\.\d+)?)(?P<unit>毫秒|ms|秒|s|分钟|分|min)" r"(?P<duration>\d+(?:\.\d+)?)(?P<unit>毫秒|ms|秒|s|分钟|分|min)"
) )
ZERO_POSITION_PATTERN = re.compile(r"归零|回零|复位|零位") ZERO_POSITION_PATTERN = re.compile(r"归零|回零|复位|零位")
@@ -126,7 +153,14 @@ MOTION_AXIS_ALIASES = {
"longitudinal": ("水平", "前后", "纵向", "滑轨"), "longitudinal": ("水平", "前后", "纵向", "滑轨"),
# Internal capability name is retained for compatibility. On the # Internal capability name is retained for compatibility. On the
# seat-ota CAN0 firmware this board axis is the physical seat-height axis. # seat-ota CAN0 firmware this board axis is the physical seat-height axis.
"cushion_tilt": ("座椅高度", "高度", "坐垫倾角", "坐盆", "坐垫"), "cushion_tilt": (
"座椅高度",
"高度",
"坐垫倾角",
"坐盆倾角",
"坐盆",
"坐垫",
),
"backrest_recline": ("靠背", "椅背"), "backrest_recline": ("靠背", "椅背"),
"legrest_height": ("腿部支撑", "小腿托", "腿托", "脚托"), "legrest_height": ("腿部支撑", "小腿托", "腿托", "脚托"),
} }
@@ -222,6 +256,7 @@ def _normalize_text(value: str) -> str:
text = re.sub(r"座椅(?:吹风|送风)", "座椅通风", text) text = re.sub(r"座椅(?:吹风|送风)", "座椅通风", text)
text = re.sub(r"座椅(?:取暖|加温)", "座椅加热", text) text = re.sub(r"座椅(?:取暖|加温)", "座椅加热", text)
text = text.replace("座椅暖一下", "座椅加热打开") text = text.replace("座椅暖一下", "座椅加热打开")
text = text.replace("推托", "腿托")
def replace_percent(match: re.Match[str]) -> str: def replace_percent(match: re.Match[str]) -> str:
number = _chinese_integer(match.group(1)) number = _chinese_integer(match.group(1))
@@ -251,6 +286,74 @@ def _relative_direction(axis: str, token: str) -> str | None:
return None return None
def _directional_motion_axis(clause: str, direction: str) -> str | None:
"""Resolve an omitted passenger-seat axis from the movement direction."""
explicit_axes = [
axis
for axis, aliases in MOTION_AXIS_ALIASES.items()
if any(alias in clause for alias in aliases)
]
if len(explicit_axes) == 1:
return explicit_axes[0]
if explicit_axes:
return None
if TARGET_PATTERN.search(clause) is None and not re.search(r"座椅|座位", clause):
return None
if direction in {
"向前",
"往前",
"朝前",
"前移",
"向后",
"往后",
"朝后",
"后移",
}:
return "longitudinal"
if direction in {
"向上",
"往上",
"抬高",
"抬起",
"升高",
"向下",
"往下",
"降低",
"降下",
}:
return "cushion_tilt"
return None
def _absolute_percent_motion_action(
axis: str, requested_position_value: float
) -> dict[str, Any]:
requested_position_pct = _round_half_away_from_zero(requested_position_value)
position_pct = max(0, min(100, requested_position_pct))
action: dict[str, Any] = {
"target": "passenger",
"action_type": "position",
"axis": axis,
"position_pct": position_pct,
}
notices: list[str] = []
if requested_position_value != requested_position_pct:
notices.append(
f"{MOTION_AXIS_LABELS[axis]}请求位置"
f"{_format_numeric_request(requested_position_value)}%;"
f"已取整为{requested_position_pct}%。"
)
if requested_position_pct != position_pct:
notices.append(
f"取整后超过0到100%行程;已采用{position_pct}%的备用目标"
"并移动到对应机械末端。"
)
if notices:
action["user_notice"] = " ".join(notices)
return action
def _context_motion_state( def _context_motion_state(
context: dict[str, Any] | None, axis: str context: dict[str, Any] | None, axis: str
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
@@ -982,6 +1085,32 @@ def parse_local_intent(
if shared_position is not None: if shared_position is not None:
continue continue
directional_absolute_axes: set[str] = set()
directional_absolute_match = DIRECTIONAL_ABSOLUTE_PERCENT_PATTERN.search(clause)
if directional_absolute_match is not None:
axis = _directional_motion_axis(
clause, directional_absolute_match.group("direction")
)
if axis is not None:
directional_absolute_axes.add(axis)
target_zh = (
_nearest_preceding_target(
clause, directional_absolute_match.start()
)
or "副驾"
)
if "driver" in TARGETS[target_zh]:
ambiguities.append(
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
)
else:
motion_actions.append(
_absolute_percent_motion_action(
axis,
float(directional_absolute_match.group("position")),
)
)
timed_axes: set[str] = set() timed_axes: set[str] = set()
timed_aliases = { timed_aliases = {
**MOTION_AXIS_ALIASES, **MOTION_AXIS_ALIASES,
@@ -1022,7 +1151,7 @@ def parse_local_intent(
relative_axes: set[str] = set() relative_axes: set[str] = set()
for axis, aliases in MOTION_AXIS_ALIASES.items(): for axis, aliases in MOTION_AXIS_ALIASES.items():
if axis in timed_axes: if axis in timed_axes or axis in directional_absolute_axes:
continue continue
for alias in aliases: for alias in aliases:
angle_match = re.search( angle_match = re.search(
@@ -1062,6 +1191,46 @@ def parse_local_intent(
motion_actions.append(action) motion_actions.append(action)
break break
directional_relative_match = DIRECTIONAL_RELATIVE_PERCENT_PATTERN.search(clause)
if (
directional_relative_match is not None
and not relative_axes
and not directional_absolute_axes
):
axis = _directional_motion_axis(
clause, directional_relative_match.group("direction")
)
if axis is not None and axis not in timed_axes:
relative_axes.add(axis)
target_zh = (
_nearest_preceding_target(
clause, directional_relative_match.start()
)
or "副驾"
)
if "driver" in TARGETS[target_zh]:
ambiguities.append(
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
)
else:
action, question = _relative_motion_action(
axis,
directional_relative_match.group("direction"),
int(directional_relative_match.group("delta")),
"percent",
context,
)
if action is None:
ambiguities.append(
{
"field": f"passenger.position.{axis}",
"question": question
or "无法计算座椅相对移动目标。",
}
)
else:
motion_actions.append(action)
endpoint_actions: dict[str, int] = {} endpoint_actions: dict[str, int] = {}
has_specific_non_longitudinal_axis = any( has_specific_non_longitudinal_axis = any(
any(alias in clause for alias in MOTION_AXIS_ALIASES[axis]) any(alias in clause for alias in MOTION_AXIS_ALIASES[axis])
@@ -1078,7 +1247,7 @@ def parse_local_intent(
endpoint_actions["longitudinal"] = 0 endpoint_actions["longitudinal"] = 0
elif re.search(r"最后面|最后|向后移到底|移到最后", clause): elif re.search(r"最后面|最后|向后移到底|移到最后", clause):
endpoint_actions["longitudinal"] = 100 endpoint_actions["longitudinal"] = 100
cushion_alias = r"(?:座椅高度|高度|坐垫倾角|坐盆|坐垫)" cushion_alias = r"(?:座椅高度|高度|坐垫倾角|坐盆倾角|坐盆|坐垫)"
if re.search( if re.search(
cushion_alias + r"(?:抬到最上方|调到最上方|移到最上方|最高|抬到头|抬到底)", cushion_alias + r"(?:抬到最上方|调到最上方|移到最上方|最高|抬到头|抬到底)",
clause, clause,
@@ -1128,7 +1297,12 @@ def parse_local_intent(
) )
for axis, aliases in MOTION_AXIS_ALIASES.items(): for axis, aliases in MOTION_AXIS_ALIASES.items():
if axis in endpoint_actions or axis in relative_axes or axis in timed_axes: if (
axis in endpoint_actions
or axis in relative_axes
or axis in timed_axes
or axis in directional_absolute_axes
):
continue continue
for alias in aliases: for alias in aliases:
position_match = re.search( position_match = re.search(
+125
View File
@@ -74,6 +74,9 @@ class LocalIntentParserTests(unittest.TestCase):
"把主驾和副驾温度都设为24度", "把主驾和副驾温度都设为24度",
"风量调到2档", "风量调到2档",
"设置主驾出风模式为吹脸", "设置主驾出风模式为吹脸",
"把副驾车窗移动到30%",
"将主驾空调温度设置为24度",
"把遮阳帘转动到20%",
) )
for utterance in utterances: for utterance in utterances:
with self.subTest(utterance=utterance): with self.subTest(utterance=utterance):
@@ -159,6 +162,128 @@ class LocalIntentParserTests(unittest.TestCase):
self.assertEqual(result["actions"][0]["position_pct"], 65) self.assertEqual(result["actions"][0]["position_pct"], 65)
self.assertEqual(result["actions"][0]["motion_intent"]["direction"], "decrease") self.assertEqual(result["actions"][0]["motion_intent"]["direction"], "decrease")
def test_parses_passenger_directional_relative_percentages(self) -> None:
context = self.motion_context()
for state in context["devices"]["passenger"]["position"].values():
state["position_pct"] = 50
cases = (
("副驾座椅往上移动10%", "cushion_tilt", "decrease", 10),
("副驾座椅往上移动20%", "cushion_tilt", "decrease", 20),
("副驾往下移动10%", "cushion_tilt", "increase", 10),
("副驾往下移动20%", "cushion_tilt", "increase", 20),
("副驾座椅往前移动10%", "longitudinal", "decrease", 10),
("副驾座椅往前移动20%", "longitudinal", "decrease", 20),
("副驾往后移动10%", "longitudinal", "increase", 10),
("副驾往后移动20%", "longitudinal", "increase", 20),
("副驾座椅靠背往前移动10%", "backrest_recline", "decrease", 10),
("副驾座椅靠背往前移动30%", "backrest_recline", "decrease", 30),
("副驾靠背往后移动10%", "backrest_recline", "increase", 10),
("副驾靠背往后移动30%", "backrest_recline", "increase", 30),
("副驾推托往上移动20%", "legrest_height", "increase", 20),
("副驾推托往上移动40%", "legrest_height", "increase", 40),
("副驾推托往下移动20%", "legrest_height", "decrease", 20),
("副驾推托往下移动40%", "legrest_height", "decrease", 40),
)
for utterance, axis, direction, delta in cases:
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance, context)
self.assertEqual(result["ambiguities"], [])
self.assertEqual(len(result["actions"]), 1)
action = result["actions"][0]
self.assertEqual(action["axis"], axis)
self.assertEqual(action["motion_intent"]["mode"], "relative")
self.assertEqual(action["motion_intent"]["direction"], direction)
self.assertEqual(action["motion_intent"]["delta_position_pct"], delta)
def test_motion_verbs_are_weak_signals_for_relative_seat_controls(self) -> None:
context = self.motion_context()
for state in context["devices"]["passenger"]["position"].values():
state["position_pct"] = 50
cases = (
("副驾座椅往前移动10%", "longitudinal", "decrease", 10),
("副驾靠背往后转动20%", "backrest_recline", "increase", 20),
("副驾腿托往上调节30%", "legrest_height", "increase", 30),
("副驾座椅高度往下设置10%", "cushion_tilt", "increase", 10),
("副驾靠背转动往前20%", "backrest_recline", "decrease", 20),
("副驾腿托设置往下30%", "legrest_height", "decrease", 30),
("副驾靠背往后旋转10度", "backrest_recline", "increase", 10),
)
for utterance, axis, direction, amount in cases:
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance, context)
self.assertEqual(result["ambiguities"], [])
self.assertEqual(len(result["actions"]), 1)
action = result["actions"][0]
self.assertEqual(action["axis"], axis)
self.assertEqual(action["motion_intent"]["mode"], "relative")
self.assertEqual(action["motion_intent"]["direction"], direction)
motion = action["motion_intent"]
requested = motion.get("requested_delta_angle_deg")
if requested is None:
requested = motion.get("requested_delta_position_pct")
if requested is None:
requested = motion.get(
"delta_angle_deg", motion.get("delta_position_pct")
)
self.assertEqual(requested, amount)
def test_directional_move_to_percentage_is_absolute_not_relative(self) -> None:
cases = (
("副驾座椅往上移动到10%", "cushion_tilt", 10),
("副驾座椅往上移动到20%", "cushion_tilt", 20),
("副驾往下移动到10%", "cushion_tilt", 10),
("副驾往下移动到20%", "cushion_tilt", 20),
("副驾座椅往前移动到10%", "longitudinal", 10),
("副驾座椅往前移动到20%", "longitudinal", 20),
("副驾往后移动到10%", "longitudinal", 10),
("副驾往后移动到20%", "longitudinal", 20),
("副驾座椅靠背往前移动到10%", "backrest_recline", 10),
("副驾座椅靠背往前移动到30%", "backrest_recline", 30),
("副驾靠背往后移动到10%", "backrest_recline", 10),
("副驾靠背往后移动到30%", "backrest_recline", 30),
("副驾推托往上移动到20%", "legrest_height", 20),
("副驾推托往上移动到40%", "legrest_height", 40),
("副驾推托往下移动到20%", "legrest_height", 20),
("副驾推托往下移动到40%", "legrest_height", 40),
)
for utterance, axis, position in cases:
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance, self.motion_context())
self.assertEqual(result["ambiguities"], [])
self.assertEqual(len(result["actions"]), 1)
action = result["actions"][0]
self.assertEqual(action["axis"], axis)
self.assertEqual(action["position_pct"], position)
self.assertNotIn("motion_intent", action)
def test_motion_verbs_with_position_markers_are_absolute(self) -> None:
cases = (
("副驾滑轨移动至60%", "longitudinal", 60),
("副驾靠背转动到30%", "backrest_recline", 30),
("副驾腿托调节至40%", "legrest_height", 40),
("副驾座椅高度设置为20%", "cushion_tilt", 20),
("副驾靠背往后转动到35%", "backrest_recline", 35),
("副驾腿托往上设置为45%", "legrest_height", 45),
("副驾靠背旋转到130度", "backrest_recline", 51),
)
for utterance, axis, position in cases:
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance, self.motion_context())
self.assertEqual(result["ambiguities"], [])
self.assertEqual(len(result["actions"]), 1)
action = result["actions"][0]
self.assertEqual(action["axis"], axis)
self.assertEqual(action["position_pct"], position)
self.assertNotIn("motion_intent", action)
def test_rejects_relative_motion_without_state_and_clamps_endpoint_overflow(self) -> None: def test_rejects_relative_motion_without_state_and_clamps_endpoint_overflow(self) -> None:
missing = parse_local_intent("靠背向前移动10度") missing = parse_local_intent("靠背向前移动10度")
context = self.motion_context() context = self.motion_context()
@@ -239,6 +239,9 @@ class ConversationStateStore:
with self._lock: with self._lock:
state = self._state(conversation_id) state = self._state(conversation_id)
passenger = state["devices"]["passenger"] passenger = state["devices"]["passenger"]
feedback_source = str(physical_state.get("source", "physical_can"))
if feedback_source not in {"physical_can", "wireless_mqtt_ch0"}:
feedback_source = "physical_can"
for axis, feedback in physical_state.get("position", {}).items(): for axis, feedback in physical_state.get("position", {}).items():
if axis not in MOTION_INITIAL_POSITIONS or not isinstance( if axis not in MOTION_INITIAL_POSITIONS or not isinstance(
feedback, dict feedback, dict
@@ -260,7 +263,7 @@ class ConversationStateStore:
position_state["position_valid"] = bool( position_state["position_valid"] = bool(
feedback["position_valid"] feedback["position_valid"]
) )
position_state["feedback_source"] = "physical_can" position_state["feedback_source"] = feedback_source
passenger["position"][str(axis)] = position_state passenger["position"][str(axis)] = position_state
for feature in THERMAL_FUNCTIONS: for feature in THERMAL_FUNCTIONS:
@@ -277,7 +280,20 @@ class ConversationStateStore:
passenger[feature] = { passenger[feature] = {
"state": state_value, "state": state_value,
"level": level, "level": level,
"feedback_source": "physical_can", "feedback_source": feedback_source,
}
dynamic_mode = physical_state.get("dynamic_mode")
if isinstance(dynamic_mode, dict):
dynamic_state = str(dynamic_mode.get("state", "idle"))
passenger["dynamic_mode"] = {
**deepcopy(dynamic_mode),
"state": (
"running"
if dynamic_state in {"entry", "running"}
else dynamic_state
),
"feedback_source": feedback_source,
} }
state["physical_state_updated_at"] = str( state["physical_state_updated_at"] = str(
@@ -0,0 +1,285 @@
"""Deterministic batches submitted by the vehicle-device control panel.
The browser sends only canonical commands from the checked CAN vocabulary.
This module validates the complete batch and builds the same intermediate
actions used by the normal compiler, without natural-language or model parsing.
"""
from __future__ import annotations
import re
from typing import Any
from cognitive_agent.application.native_can_intent import (
parse_exact_native_can_command,
)
MAX_DIRECT_CAN_COMMANDS = 32
MAX_DIRECT_CAN_COMMAND_LENGTH = 160
_PASSENGER_POSITION_COMMAND = re.compile(
r"seat passenger "
r"(horizontal|basin|backrest|legrest|longitudinal|height) "
r"position (\d+)"
)
_PASSENGER_RELATIVE_COMMAND = re.compile(
r"seat passenger "
r"(horizontal|basin|backrest|legrest|longitudinal|height) "
r"relative (forward|backward|up|down) (\d+)"
)
_PASSENGER_THERMAL_COMMAND = re.compile(
r"seat passenger (vent|heat) (off|low|medium|high)"
)
_PASSENGER_DYNAMIC_COMMAND = re.compile(
r"seat passenger dynamic (?:(?:start )?(rocking-horse|cradle)|(stop))"
)
_PASSENGER_AXES = {
"horizontal": "longitudinal",
"basin": "cushion_tilt",
"longitudinal": "longitudinal",
"height": "cushion_tilt",
"backrest": "backrest_recline",
"legrest": "legrest_height",
}
_PASSENGER_RELATIVE_DIRECTIONS = {
"horizontal": {"forward": "decrease", "backward": "increase"},
"longitudinal": {"forward": "decrease", "backward": "increase"},
"basin": {"up": "decrease", "down": "increase"},
"height": {"up": "decrease", "down": "increase"},
"backrest": {"forward": "decrease", "backward": "increase"},
"legrest": {"up": "increase", "down": "decrease"},
}
_PASSENGER_THERMAL_LEVELS = {"low": 1, "medium": 2, "high": 3}
def _empty_direct_intent() -> dict[str, Any]:
return {
"actions": [],
"domain_actions": [],
"native_can_actions": [],
"preset_actions": [],
"preset_events": [],
"deferred_requests": [],
"model_requests": [],
"ambiguities": [],
"decision_summary": [],
"semantic_constraints": [],
"unresolved_spans": [],
"detected_domains": [],
"confidence": 1.0,
}
def build_direct_can_intent(
commands: object,
context: dict[str, Any] | None = None,
) -> tuple[list[str], dict[str, Any]]:
"""Validate a canonical command batch and return compiler-ready actions."""
if not isinstance(commands, list) or not commands:
raise ValueError("can_commands must be a non-empty array")
if len(commands) > MAX_DIRECT_CAN_COMMANDS:
raise ValueError(
f"can_commands must contain at most {MAX_DIRECT_CAN_COMMANDS} commands"
)
normalized_commands: list[str] = []
parsed = _empty_direct_intent()
seen_commands: set[str] = set()
for raw_command in commands:
if not isinstance(raw_command, str):
raise ValueError("every can command must be a string")
command = " ".join(raw_command.strip().lower().split())
if not command or len(command) > MAX_DIRECT_CAN_COMMAND_LENGTH:
raise ValueError(
"every can command must contain 1 to "
f"{MAX_DIRECT_CAN_COMMAND_LENGTH} characters"
)
if command in seen_commands:
continue
native_actions = parse_exact_native_can_command(command)
if native_actions:
for action in native_actions:
direct_action = dict(action)
direct_action["parse_source"] = "direct_control"
direct_action["source_span"] = command
parsed["native_can_actions"].append(direct_action)
parsed["decision_summary"].append(
f"设备控件直接提交白名单命令:{command}"
)
domain = str(direct_action.get("domain", "vehicle"))
if domain not in parsed["detected_domains"]:
parsed["detected_domains"].append(domain)
else:
passenger_position = _PASSENGER_POSITION_COMMAND.fullmatch(command)
if passenger_position:
position = int(passenger_position[2])
if not 0 <= position <= 100:
raise ValueError(
f"CAN0 passenger-seat position is out of range: {command}"
)
parsed["actions"].append(
{
"target": "passenger",
"action_type": "position",
"axis": _PASSENGER_AXES[passenger_position[1]],
"position_pct": position,
"parse_source": "direct_control",
"source_span": command,
}
)
parsed["decision_summary"].append(
f"设备控件直接提交 CAN0 白名单命令:{command}"
)
if "seat" not in parsed["detected_domains"]:
parsed["detected_domains"].append("seat")
else:
relative = _PASSENGER_RELATIVE_COMMAND.fullmatch(command)
if relative:
semantic_axis = relative[1]
axis = _PASSENGER_AXES[semantic_axis]
direction = relative[2]
operation = _PASSENGER_RELATIVE_DIRECTIONS.get(
semantic_axis, {}
).get(direction)
delta = int(relative[3])
if operation is None:
raise ValueError(
f"CAN0 passenger-seat direction is invalid: {command}"
)
if not 1 <= delta <= 100:
raise ValueError(
f"CAN0 passenger-seat relative amount is out of range: {command}"
)
position_state = (
(context or {})
.get("devices", {})
.get("passenger", {})
.get("position", {})
.get(axis, {})
)
current = position_state.get("position_pct")
if (
isinstance(current, bool)
or not isinstance(current, (int, float))
or not 0 <= float(current) <= 100
):
raise ValueError(
"CAN0 passenger-seat relative control requires a valid "
f"current {axis} position"
)
signed_delta = delta if operation == "increase" else -delta
raw_target = round(float(current)) + signed_delta
target = max(0, min(100, raw_target))
applied_delta = abs(target - round(float(current)))
parsed["actions"].append(
{
"target": "passenger",
"action_type": "position",
"axis": axis,
"position_pct": target,
"motion_intent": {
"mode": "relative",
"direction": operation,
"start_position_pct": round(float(current)),
"delta_position_pct": applied_delta,
**(
{
"requested_delta_position_pct": delta,
"range_clamped": True,
}
if applied_delta != delta
else {}
),
},
"parse_source": "direct_control",
"source_span": command,
}
)
parsed["decision_summary"].append(
f"设备控件直接提交 CAN0 相对移动白名单命令:{command}"
)
if "seat" not in parsed["detected_domains"]:
parsed["detected_domains"].append("seat")
else:
thermal = _PASSENGER_THERMAL_COMMAND.fullmatch(command)
if thermal:
setting = thermal[2]
action = {
"target": "passenger",
"function": (
"ventilation" if thermal[1] == "vent" else "heating"
),
"state": "off" if setting == "off" else "on",
"parse_source": "direct_control",
"source_span": command,
}
if setting != "off":
action["requested_level"] = _PASSENGER_THERMAL_LEVELS[
setting
]
parsed["actions"].append(action)
parsed["decision_summary"].append(
f"设备控件直接提交 CAN0 热舒适白名单命令:{command}"
)
if "seat" not in parsed["detected_domains"]:
parsed["detected_domains"].append("seat")
normalized_commands.append(command)
seen_commands.add(command)
continue
dynamic = _PASSENGER_DYNAMIC_COMMAND.fullmatch(command)
if dynamic and dynamic[1]:
mode = dynamic[1]
parsed["actions"].append(
{
"target": "passenger",
"action_type": "dynamic_mode",
"mode_id": 1 if mode == "rocking-horse" else 2,
"mode_name": (
"木马模式"
if mode == "rocking-horse"
else "动态摇篮模式"
),
"repeat": "until_stopped_or_timeout",
"max_runtime_ms": 600_000,
"parse_source": "direct_control",
"source_span": command,
}
)
parsed["decision_summary"].append(
f"设备控件直接提交 CAN0 白名单命令:{command}"
)
if "seat" not in parsed["detected_domains"]:
parsed["detected_domains"].append("seat")
elif dynamic and dynamic[2] == "stop":
parsed["actions"].append(
{
"target": "passenger",
"action_type": "dynamic_mode_stop",
"parse_source": "direct_control",
"source_span": command,
}
)
parsed["decision_summary"].append(
f"设备控件直接提交 CAN0 白名单命令:{command}"
)
if "seat" not in parsed["detected_domains"]:
parsed["detected_domains"].append("seat")
else:
raise ValueError(
f"CAN control command is not in the exact whitelist: {command}"
)
normalized_commands.append(command)
seen_commands.add(command)
if not normalized_commands:
raise ValueError("can_commands must contain at least one unique command")
return normalized_commands, parsed
@@ -76,19 +76,43 @@ class MqttOtaSeatExecutor:
capability = str(operation["capability"]) capability = str(operation["capability"])
parameters = operation.get("parameters", {}) parameters = operation.get("parameters", {})
axes = { axes = {
"seat.set_longitudinal_position": "longitudinal", "seat.set_longitudinal_position": "horizontal",
"seat.set_cushion_tilt": "height", "seat.set_cushion_tilt": "basin",
"seat.set_backrest_recline": "backrest", "seat.set_backrest_recline": "backrest",
"seat.set_legrest_height": "legrest", "seat.set_legrest_height": "legrest",
} }
if capability in axes: 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 ( return (
f"seat passenger {axes[capability]} position " f"seat passenger {axes[capability]} position "
f"{parameters['position_pct']}" 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": if capability == "seat.start_dynamic_mode":
mode = "rocking-horse" if int(parameters["mode_id"]) == 1 else "cradle" mode = "rocking-horse" if int(parameters["mode_id"]) == 1 else "cradle"
return f"seat passenger dynamic start {mode}" return f"seat passenger dynamic {mode}"
if capability == "seat.stop_dynamic_mode": if capability == "seat.stop_dynamic_mode":
return "seat passenger dynamic stop" return "seat passenger dynamic stop"
return capability return capability
@@ -384,6 +408,7 @@ class MqttOtaSeatExecutor:
try: try:
bus = self.bus_factory() bus = self.bus_factory()
previous_offset = 0 previous_offset = 0
thermal_publication_start: int | None = None
for command in command_plan["spec"]["commands"]: for command in command_plan["spec"]["commands"]:
offset = int(command.get("send_offset_ms", 0)) offset = int(command.get("send_offset_ms", 0))
if offset > previous_offset: if offset > previous_offset:
@@ -393,8 +418,10 @@ class MqttOtaSeatExecutor:
operations[item] operations[item]
for item in command["operation_ref"].split(",") for item in command["operation_ref"].split(",")
] ]
publication_start = len( publication_start = (
getattr(bus, "published_messages", ()) 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) set_debug_command = getattr(bus, "set_debug_command", None)
if callable(set_debug_command): if callable(set_debug_command):
@@ -404,24 +431,40 @@ class MqttOtaSeatExecutor:
for item in referenced for item in referenced
) )
) )
if command["message"] != "T2G_Seat_Move_Command_300": if command["message"] == "T2G_Seat_Thermal_Mode_3CE":
raise HardwareExecutionError( encoded = self.status_executor.matrix.encode(
"seat-ota firmware does not provide wireless thermal control; " command["message"], command["signals"]
f"{command['message']} was not sent"
) )
opcode = int(command["signals"]["Opcode"]) bus.send(
if opcode in {0x0E, 0x0F}: int(encoded["frame_id"], 16),
motion_armed = opcode == 0x0E bytes.fromhex(encoded["data_hex"]),
command_results = self._execute_dynamic( )
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 bus, command, referenced
) )
motion_armed = False 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: else:
motion_armed = True raise HardwareExecutionError(
command_results = self._execute_move( "wireless CAN0 command is not in the passenger-seat runtime "
bus, command, referenced f"whitelist: {command['message']}"
) )
motion_armed = False
mqtt_messages = list( mqtt_messages = list(
getattr(bus, "published_messages", ())[ getattr(bus, "published_messages", ())[
publication_start: publication_start:
@@ -491,6 +534,7 @@ class MqttOtaSeatExecutor:
"labels": { "labels": {
"simulation": "false", "simulation": "false",
"feedback": "wireless_mqtt_ch0", "feedback": "wireless_mqtt_ch0",
"seat_feedback": "wireless_mqtt_ch0",
}, },
}, },
"spec": spec, "spec": spec,
@@ -60,6 +60,65 @@ LIGHT_TARGETS = {
"右后": "rear_right", "右后": "rear_right",
} }
CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+") CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+")
OUTLET_MODE_DISABLE_PATTERN = re.compile(
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?"
r"(?P<feature>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?|"
r"(?P<feature_after>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?"
r"(?:关闭|关掉|停止|取消)"
)
DRIVER_SEAT_THERMAL_MIDDLE_PATTERN = re.compile(
r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}"
r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
)
_FEATURE_BOUNDARY = re.compile(
r"并且|同时|然后|随后|接着|以及|并|再|和|与|、|,"
)
def _feature_context(text: str, feature: str, radius: int = 24) -> str:
"""Keep modifiers attached to one seat feature instead of a neighbour."""
match = re.search(feature, text)
if match is None:
return ""
prefix = text[max(0, match.start() - radius) : match.start()]
suffix = text[match.end() : match.end() + radius]
prefix = _FEATURE_BOUNDARY.split(prefix)[-1]
suffix = _FEATURE_BOUNDARY.split(suffix)[0]
return f"{prefix}{match.group(0)}{suffix}"
def _grade_alias(text: str, *, include_numeric: bool = True) -> str | None:
"""Normalize common low/middle/high wording without guessing a value."""
low = (
r"最小(?:档|级)?|最低(?:档|级)?|最弱(?:档|级)?|弱(?:档|级)?|"
r"低(?:档|级)?|调低"
)
middle = (
r"中等(?:档|级)?|中档|中级|中间(?:档|级)?|适中|"
r"(?:调到|调为|调|设为|设置为|调整到|调整为)中(?:档|级)?"
)
high = (
r"最大(?:档|级)?|最高(?:档|级)?|最强(?:档|级)?|"
r"强(?!度)(?:档|级)?|高(?:档|级)?|调高"
)
if re.search(low, text):
return "low"
if re.search(high, text):
return "high"
if re.search(middle, text):
return "medium"
if include_numeric:
if re.search(r"(?:1|一)(?:档|级)", text):
return "low"
if re.search(r"(?:2|二)(?:档|级)", text):
return "medium"
if re.search(r"(?:3|三)(?:档|级)", text):
return "high"
return None
# Defaults are intentionally scoped to the driver console and verified CH1 # Defaults are intentionally scoped to the driver console and verified CH1
# body-CAN vocabulary. A missing target must never become the legacy # body-CAN vocabulary. A missing target must never become the legacy
@@ -463,6 +522,36 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
through to the legacy CH0/T2G passenger-seat implementation. through to the legacy CH0/T2G passenger-seat implementation.
""" """
seat_clauses = [
clause for clause in CLAUSE_SEPARATOR.split(compact) if clause
]
if len(seat_clauses) > 1:
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:
if re.search(r"主驾|驾驶位|司机位|左前座椅", clause):
inherited_driver_scope = True
elif re.search(r"副驾|副驾驶|乘客|右前", clause):
inherited_driver_scope = False
elif inherited_driver_scope:
clause = f"主驾座椅{clause}"
for action in _parse_chinese_driver_seat_commands(clause):
signature = (
str(action.get("capability", "")),
str(action.get("target", "")),
tuple(
sorted(
(str(key), repr(value))
for key, value in action.get("parameters", {}).items()
)
),
)
if signature not in seen:
seen.add(signature)
combined_actions.append(action)
return combined_actions
if re.search( if re.search(
r"副驾(?:驶|位)?|乘客(?:位|座|座椅|座位)?|右前(?:座|座椅|座位)?|" r"副驾(?:驶|位)?|乘客(?:位|座|座椅|座位)?|右前(?:座|座椅|座位)?|"
r"前排|后排|左后|右后|所有座椅|全部座椅|全车座椅|" r"前排|后排|左后|右后|所有座椅|全部座椅|全车座椅|"
@@ -508,14 +597,20 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
present_thermal = [item for item in thermal_features if item[0] in compact] present_thermal = [item for item in thermal_features if item[0] in compact]
if len(present_thermal) == 1: if len(present_thermal) == 1:
label, capability = present_thermal[0] label, capability = present_thermal[0]
thermal_text = _feature_context(compact, label)
thermal_grade = _grade_alias(thermal_text, include_numeric=False)
parameters: dict[str, Any] | None = None parameters: dict[str, Any] | None = None
if re.search(r"关闭|关掉|停止|取消|不要|别开", compact): if re.search(r"关闭|关掉|停止|取消|不要|别开", thermal_text):
parameters = {"state": "off"} parameters = {"state": "off"}
elif re.search(r"高档|高速|最高|强档|调高|1档", compact): elif thermal_grade == "high" or re.search(
r"高速|(?:1|一)(?:档|级)", thermal_text
):
parameters = {"state": "on", "level": 1} parameters = {"state": "on", "level": 1}
elif re.search(r"低档|低速|最低|弱档|调低|2档", compact): elif thermal_grade == "low" or re.search(
r"低速|(?:2|二)(?:档|级)", thermal_text
):
parameters = {"state": "on", "level": 2} parameters = {"state": "on", "level": 2}
elif re.search(r"打开|开启|启动", compact): elif re.search(r"打开|开启|启动|启用", thermal_text):
parameters = {"state": "on", "level": 2} parameters = {"state": "on", "level": 2}
if parameters is not None: if parameters is not None:
actions.append(_action( actions.append(_action(
@@ -531,9 +626,10 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
)) ))
if "按摩" in compact: if "按摩" in compact:
massage: dict[str, Any] | None = None massage_text = _feature_context(compact, "按摩")
if re.search(r"关闭|关掉|停止|取消", compact): massage_actions: list[dict[str, Any]] = []
massage = {"action": "off"} if re.search(r"关闭|关掉|停止|取消|不要|别开", massage_text):
massage_actions.append({"action": "off"})
else: else:
modes = ( modes = (
(r"波浪", "wave"), (r"波浪", "wave"),
@@ -543,67 +639,122 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
(r"舒展|放松", "relax"), (r"舒展|放松", "relax"),
) )
mode = next( mode = next(
(value for pattern, value in modes if re.search(pattern, compact)), (
value
for pattern, value in modes
if re.search(pattern, massage_text)
),
None, None,
) )
if mode is not None: if mode is not None:
massage = {"action": "mode", "mode": mode} massage_actions.append({"action": "mode", "mode": mode})
elif re.search(r"强度|力度|弱|中等|中档|强|[123]档", compact): elif re.search(r"打开|开启|启动|启用", massage_text):
strength = ( # The verified vocabulary has no state-only ON frame. Starting
1 # the conservative relax mode is the deterministic ON action.
if re.search(r"弱|低|1档", compact) massage_actions.append({"action": "mode", "mode": "relax"})
else 2
if re.search(r"中等|中档|2档", compact) massage_grade = _grade_alias(massage_text)
else 3 strength_requested = bool(
if re.search(r"强(?!度)|高|3档", compact) re.search(
else 2 r"强度|力度|档位|等级|最小|最大|最低|最高|最弱|最强|"
r"弱|中等|中档|中级|中间|适中|强(?!度)|"
r"(?:[123一二三])(?:档|级)",
massage_text,
) )
massage = {"action": "strength", "strength": strength} )
if massage is not None: if strength_requested and massage_grade is not None:
actions.append(_action( massage_actions.append(
"seat", {
"seat.set_driver_massage_native", "action": "strength",
"driver", "strength": {"low": 1, "medium": 2, "high": 3}[
massage, massage_grade
( ],
"用户明确指定了 CH1 主驾座椅按摩控制。" }
if explicit_driver )
else default_reason for massage in massage_actions:
), actions.append(
)) _action(
"seat",
"seat.set_driver_massage_native",
"driver",
massage,
(
"用户明确指定了 CH1 主驾座椅按摩控制。"
if explicit_driver
else default_reason
),
)
)
stop_all_axes = bool(
re.search(
r"(?:停止|停下|暂停)(?:主驾)?(?:座椅)?(?:所有)?(?:移动|调节|动作)|"
r"(?:主驾)?座椅(?:停止|停下|暂停)(?:移动|调节|动作)",
compact,
)
)
if stop_all_axes:
for stopped_axis in ("horizontal", "height", "backrest", "basin"):
actions.append(
_action(
"seat",
"seat.set_driver_axis_native",
"driver",
{
"axis": stopped_axis,
"direction": "stop",
"duration_ms": 500,
},
(
"用户明确要求停止 CH1 主驾座椅全部四轴动作。"
if explicit_driver
else default_reason
),
)
)
return actions
axis: str | None = None axis: str | None = None
direction: str | None = None direction: str | None = None
if re.search(r"坐盆|坐垫", compact): if re.search(r"坐盆|坐垫|座垫", compact):
axis = "basin" axis = "basin"
direction = ( direction = (
"stop" if re.search(r"停止|停下|暂停", compact) "stop" if re.search(r"停止|停下|暂停", compact)
else "up" if re.search(r"抬高|升高|向上|往上", compact) else "up" if re.search(r"抬高|抬起|升高|翘起|上调|向上|往上", compact)
else "down" if re.search(r"降低|下降|向下|往下", compact) else "down" if re.search(r"降低|下降|降下|放低|下调|向下|往下", compact)
else None else None
) )
elif "靠背" in compact: elif re.search(r"靠背|椅背", compact):
axis = "backrest" axis = "backrest"
direction = ( direction = (
"stop" if re.search(r"停止|停下|暂停", compact) "stop" if re.search(r"停止|停下|暂停", compact)
else "backward" if re.search(r"后仰|向后|往后", compact) else "backward" if re.search(r"后仰|放倒|躺倒|调躺|后倾|向后|往后|朝后", compact)
else "forward" if re.search(r"前倾|向前|往前|立直", compact) else "forward" if re.search(r"前倾|调直|立直|竖直|竖起|立起|向前|往前|朝前", compact)
else None else None
) )
elif re.search(r"座椅高度|整椅|座椅升高|座椅降低|座椅调高|座椅调低", compact): elif re.search(
r"座椅高度|整椅|座椅升高|座椅降低|座椅调高|座椅调低|"
r"座椅(?:向|往|朝)?(?:上|下)(?:移|移动|调|调整)?|座椅上移|座椅下移",
compact,
):
axis = "height" axis = "height"
direction = ( direction = (
"stop" if re.search(r"停止|停下|暂停", compact) "stop" if re.search(r"停止|停下|暂停", compact)
else "up" if re.search(r"抬高|升高|上升|向上|往上|调高", compact) else "up" if re.search(r"抬高|抬起|升高|上升|上移|向上|往上|朝上|调高", compact)
else "down" if re.search(r"降低|下降|向下|往下|调低", compact) else "down" if re.search(r"降低|下降|降下|下移|向下|往下|朝下|调低|放低", compact)
else None else None
) )
elif re.search(r"座椅前后|水平|前移|后移|向前移动|向后移动|座椅(?:向|往)?(?:前|后)", compact): elif re.search(
r"座椅前后|水平|滑轨|前移|后移|前进|后退|前推|后拉|"
r"(?:向|往|朝)(?:前|后)(?:移动|挪动|挪|调节|调整|调|滑动)?|"
r"座椅(?:向|往|朝)?(?:前|后)",
compact,
):
axis = "horizontal" axis = "horizontal"
direction = ( direction = (
"stop" if re.search(r"停止|停下|暂停", compact) "stop" if re.search(r"停止|停下|暂停", compact)
else "forward" if re.search(r"前移|向前|往前", compact) else "forward" if re.search(r"前移|前进|前推|向前|往前|朝前", compact)
else "backward" if re.search(r"后移|向后|往后", compact) else "backward" if re.search(r"后移|后退|后拉|向后|往后|朝后", compact)
else None else None
) )
if axis is not None and direction is not None: if axis is not None and direction is not None:
@@ -624,14 +775,22 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
second_match = re.search( second_match = re.search(
r"(\d+(?:\.\d+)?)(?:秒|s)", compact, re.IGNORECASE r"(\d+(?:\.\d+)?)(?:秒|s)", compact, re.IGNORECASE
) )
chinese_second_match = re.search(r"(半|一|二|两)(?:秒|s)", compact)
unsupported_duration = "分钟" in compact unsupported_duration = "分钟" in compact
if millisecond_match and second_match: if sum(
bool(item)
for item in (millisecond_match, second_match, chinese_second_match)
) > 1:
unsupported_duration = True unsupported_duration = True
duration = 500 duration = 500
if direction != "stop" and millisecond_match: if direction != "stop" and millisecond_match:
duration = int(millisecond_match[1]) duration = int(millisecond_match[1])
elif direction != "stop" and second_match: elif direction != "stop" and second_match:
duration = round(float(second_match[1]) * 1000) duration = round(float(second_match[1]) * 1000)
elif direction != "stop" and chinese_second_match:
duration = {"半": 500, "一": 1000, "二": 2000, "两": 2000}[
chinese_second_match[1]
]
if ( if (
not unsupported_absolute_goal not unsupported_absolute_goal
and not unsupported_duration and not unsupported_duration
@@ -652,6 +811,111 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
return actions return actions
def _positive_outlet_feature(text: str, pattern: str) -> bool:
"""Return whether an outlet feature is requested rather than disabled."""
for match in re.finditer(pattern, text):
prefix = text[max(0, match.start() - 16) : match.start()]
suffix = text[match.end() : match.end() + 12]
disabled_before = re.search(
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?$",
prefix,
)
disabled_after = re.match(
r"(?:出风口|出风|模式)?(?:关闭|关掉|停止|取消)",
suffix,
)
if disabled_before is None and disabled_after is None:
return True
return False
def _parse_chinese_window_commands(compact: str) -> list[dict[str, Any]]:
"""Parse each window clause independently so targets and values cannot leak."""
actions: list[dict[str, Any]] = []
clauses = [clause for clause in CLAUSE_SEPARATOR.split(compact) if clause]
exclusion_pattern = re.compile(
r"^(?:主驾|主驾驶|驾驶位|司机位|副驾|副驾驶|乘客位|"
r"左前|前左|右前|前右|左后|后左|右后|后右)"
r"(?:车窗)?(?:除外|除去|不包括|不含)$"
)
for index, clause in enumerate(clauses):
if "车窗" not in clause:
continue
window_text = clause
if index + 1 < len(clauses) and exclusion_pattern.search(
clauses[index + 1]
):
window_text = f"{window_text},{clauses[index + 1]}"
targets, explicit_window_target = _window_targets(window_text)
position = re.search(
r"(?:位置|开度|调整到|调到|设为|设置为|开启到|打开到|开到)"
r"(\d{1,3})%",
window_text,
)
half_position = bool(
re.search(
r"(?:开启|打开|开到|开度)?(?:一半|半开)",
window_text,
)
)
position_pct = (
int(position[1]) if position else 50 if half_position else None
)
if (
position_pct is not None
and all(target not in WINDOW_GROUP_MEMBERS for target in targets)
and position_pct <= 100
):
reason = (
"用户指定了单个车窗的绝对开度。"
if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
_action(
"window",
"window.set_position",
target,
{"position_pct": position_pct},
reason,
)
for target in targets
)
continue
if position_pct is not None:
# Absolute feedback is verified for one window only.
continue
motion = (
"stop"
if re.search(r"停止|停下|暂停", window_text)
else "close"
if re.search(r"关闭|关上|升起|关窗", window_text)
else "open"
if re.search(r"打开|开启|降下|开窗", window_text)
else None
)
if motion:
reason = (
"用户指定了车窗目标和动作。"
if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
_action(
"window",
"window.set_motion",
target,
{"motion": motion},
reason,
)
for target in targets
)
return actions
def _parse_chinese_commands(text: str) -> list[dict[str, Any]]: def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
compact = re.sub(r"\s+", "", text) compact = re.sub(r"\s+", "", text)
seat_text = _scoped_text( seat_text = _scoped_text(
@@ -726,8 +990,14 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
r"([1-7])(?:档|级)?(?![\d.])", r"([1-7])(?:档|级)?(?![\d.])",
climate_text, climate_text,
) )
if fan: fan_level = int(fan[1]) if fan else None
actions.append(_action("climate", "climate.set_fan_level", "vehicle", {"level": int(fan[1])}, "用户指定了空调风量。")) if fan_level is None and re.search(r"风量|风速", climate_text):
fan_grade = _grade_alias(climate_text, include_numeric=False)
fan_level = {"low": 1, "medium": 4, "high": 7}.get(
fan_grade or ""
)
if fan_level is not None:
actions.append(_action("climate", "climate.set_fan_level", "vehicle", {"level": fan_level}, "用户指定了空调风量。"))
if re.search(r"自动(?:控制|模式)", climate_text): if re.search(r"自动(?:控制|模式)", climate_text):
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "auto"}, "用户指定空调自动控制。")) actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "auto"}, "用户指定空调自动控制。"))
@@ -739,9 +1009,9 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
elif re.search(r"外循环", climate_text): elif re.search(r"外循环", climate_text):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。")) actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。"))
has_face = bool(re.search(r"吹面|吹脸", climate_text)) has_face = _positive_outlet_feature(climate_text, r"吹面|吹脸")
has_feet = "吹脚" in climate_text has_feet = _positive_outlet_feature(climate_text, r"吹脚")
has_defrost = "除霜" in climate_text has_defrost = _positive_outlet_feature(climate_text, r"除霜")
outlet_mode = ( outlet_mode = (
"face_feet_defrost" "face_feet_defrost"
if has_face and has_feet and has_defrost if has_face and has_feet and has_defrost
@@ -770,60 +1040,7 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
) )
) )
window_text = _scoped_text( actions.extend(_parse_chinese_window_commands(compact))
compact,
r"车窗",
r"^(?:主驾|主驾驶|驾驶位|司机位|副驾|副驾驶|乘客位|"
r"左前|前左|右前|前右|左后|后左|右后|后右)(?:车窗)?(?:除外|除去|不包括|不含)$",
)
if window_text:
targets, explicit_window_target = _window_targets(window_text)
position = re.search(
r"(?:位置|开度|调整到|调到|设为|设置为|开启到|打开到|开到)(\d{1,3})%",
window_text,
)
half_position = bool(
re.search(
r"(?:开启|打开|开到|开度)?(?:一半|半开)",
window_text,
)
)
position_pct = int(position[1]) if position else 50 if half_position else None
if (
position_pct is not None
and all(target not in WINDOW_GROUP_MEMBERS for target in targets)
and position_pct <= 100
):
reason = (
"用户指定了单个车窗的绝对开度。"
if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
_action("window", "window.set_position", target, {"position_pct": position_pct}, reason)
for target in targets
)
elif position_pct is not None:
# Absolute position feedback is verified for one window only. Do
# not turn a half-open request for a broad scope into full motion.
pass
else:
motion = (
"stop" if re.search(r"停止|停下|暂停", window_text)
else "close" if re.search(r"关闭|关上|升起|关窗", window_text)
else "open" if re.search(r"打开|开启|降下|开窗", window_text)
else None
)
if motion:
reason = (
"用户指定了车窗目标和动作。"
if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
_action("window", "window.set_motion", target, {"motion": motion}, reason)
for target in targets
)
door_text = _scoped_text(compact, r"门锁|车门|后门|锁车|解锁车辆") door_text = _scoped_text(compact, r"门锁|车门|后门|锁车|解锁车辆")
rear_lock_request = bool( rear_lock_request = bool(
@@ -931,8 +1148,18 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
) )
if color_match and 1 <= int(color_match[1]) <= 127: if color_match and 1 <= int(color_match[1]) <= 127:
actions.append(_action("ambient_light", "ambient_light.set_color_index", "vehicle", {"color_index": int(color_match[1])}, "用户指定了已验证的氛围灯颜色索引。")) actions.append(_action("ambient_light", "ambient_light.set_color_index", "vehicle", {"color_index": int(color_match[1])}, "用户指定了已验证的氛围灯颜色索引。"))
if brightness_match: brightness_level = int(brightness_match[1]) if brightness_match else None
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": int(brightness_match[1])}, "用户指定了已验证的氛围灯亮度档位。")) if brightness_level is None and re.search(r"亮度|强度", ambient_text):
brightness_grade = _grade_alias(
ambient_text, include_numeric=False
)
brightness_level = {
"low": 1,
"medium": 3,
"high": 5,
}.get(brightness_grade or "")
if brightness_level is not None:
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": brightness_level}, "用户指定了已验证的氛围灯亮度档位。"))
outlet_text = _scoped_text(compact, r"风口") 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 f"{label}风口" in outlet_text), None)
@@ -1204,6 +1431,89 @@ def merge_native_can_intent(
unresolved=not has_default_driver_seat_action, unresolved=not has_default_driver_seat_action,
) )
if not actions and not relative_ambiguities: if not actions and not relative_ambiguities:
unsupported_thermal_middle = DRIVER_SEAT_THERMAL_MIDDLE_PATTERN.search(
utterance
)
if unsupported_thermal_middle is not None and not re.search(
r"副驾|副驾驶|乘客|右前", utterance
):
result = dict(parsed)
feature = (
"通风"
if "通风" in unsupported_thermal_middle.group(0)
else "加热"
)
capability = (
"seat.set_driver_ventilation_native"
if feature == "通风"
else "seat.set_driver_heating_native"
)
constraint = {
"type": "unsupported_discrete_level",
"domain": "seat",
"capability": capability,
"parameters": {"requested_level": "medium"},
"source_span": {
"text": unsupported_thermal_middle.group(0),
"start": unsupported_thermal_middle.start(),
"end": unsupported_thermal_middle.end(),
},
}
constraints = list(result.get("semantic_constraints", []))
if not any(
item.get("type") == constraint["type"]
and item.get("capability") == capability
and item.get("parameters") == constraint["parameters"]
for item in constraints
):
constraints.append(constraint)
result["semantic_constraints"] = constraints
result["ambiguities"] = [
item
for item in result.get("ambiguities", [])
if not (
str(item.get("field", "")) in {"target", "target_scope"}
and "仅控制副驾" in str(item.get("question", ""))
)
]
result["decision_summary"] = [
item
for item in result.get("decision_summary", [])
if not _is_stale_passenger_seat_summary(item)
]
domains = list(result.get("detected_domains", []))
if "seat" not in domains:
domains.append("seat")
result["detected_domains"] = domains
result["confidence"] = max(float(result.get("confidence", 0)), 0.99)
return result
disabled_outlet = OUTLET_MODE_DISABLE_PATTERN.search(utterance)
if disabled_outlet is not None:
feature = (
disabled_outlet.group("feature")
or disabled_outlet.group("feature_after")
)
result = dict(parsed)
result["semantic_constraints"] = [
*result.get("semantic_constraints", []),
{
"type": "requires_absolute_outlet_mode",
"domain": "climate",
"capability": "climate.set_outlet_mode",
"parameters": {"disabled_feature": feature},
"source_span": {
"text": disabled_outlet.group(0),
"start": disabled_outlet.start(),
"end": disabled_outlet.end(),
},
},
]
domains = list(result.get("detected_domains", []))
if "climate" not in domains:
domains.append("climate")
result["detected_domains"] = domains
result["confidence"] = max(float(result.get("confidence", 0)), 0.99)
return result
return parsed return parsed
result = dict(parsed) result = dict(parsed)
@@ -1391,6 +1701,20 @@ def merge_native_can_intent(
} }
for item in actions for item in actions
) )
resolved_separate_thermal_and_massage = (
any(
item["capability"]
in {
"seat.set_driver_ventilation_native",
"seat.set_driver_heating_native",
}
for item in actions
)
and any(
item["capability"] == "seat.set_driver_massage_native"
for item in actions
)
)
result["ambiguities"] = [ result["ambiguities"] = [
item item
for item in result.get("ambiguities", []) for item in result.get("ambiguities", [])
@@ -1410,6 +1734,10 @@ def merge_native_can_intent(
) )
and re.search(r"主驾|driver|座椅", str(item.get("question", "")), re.IGNORECASE) and re.search(r"主驾|driver|座椅", str(item.get("question", "")), re.IGNORECASE)
) )
and not (
resolved_separate_thermal_and_massage
and str(item.get("field", "")) == "passenger.thermal.state"
)
] ]
existing_summaries = list(result.get("decision_summary", [])) existing_summaries = list(result.get("decision_summary", []))
if unique_actions and not forbidden_seat_scope and not result.get("actions"): if unique_actions and not forbidden_seat_scope and not result.get("actions"):
+151 -58
View File
@@ -57,6 +57,9 @@ from cognitive_agent.application.mqtt_ota_seat_executor import ( # noqa: E402
MqttOtaSeatExecutor, MqttOtaSeatExecutor,
) )
from cognitive_agent.application.native_can_intent import merge_native_can_intent # noqa: E402 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,
)
from cognitive_agent.application.native_can_relative import ( # noqa: E402 from cognitive_agent.application.native_can_relative import ( # noqa: E402
NativeCanRelativeResolver, NativeCanRelativeResolver,
) )
@@ -1401,6 +1404,47 @@ class SeatDemoService:
} }
return merged return merged
def process_can_commands(
self,
commands: object,
conversation_id: str | None = None,
execution_mode: str = "dry_run",
approval_token: str | None = None,
vehicle_id: str | None = None,
simulation_fault: str = "normal",
*,
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
"""Compile and execute an exact UI command batch without intent parsing."""
resolved_conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
if not CONVERSATION_ID_PATTERN.fullmatch(resolved_conversation_id):
raise ValueError(
"conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
)
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
context = self._context_for_vehicle(
self.conversations.snapshot(resolved_conversation_id),
vehicle_profile,
)
direct_can_batch = build_direct_can_intent(commands, context)
normalized_commands, _parsed = direct_can_batch
return self.process(
";".join(normalized_commands),
provider="local",
conversation_id=resolved_conversation_id,
execution_mode=execution_mode,
approval_token=approval_token,
vehicle_id=vehicle_id,
simulation_fault=simulation_fault,
hardware_client_id=hardware_client_id,
hardware_access_bypass=hardware_access_bypass,
control_transport=control_transport,
_direct_can_batch=direct_can_batch,
)
def process( def process(
self, self,
utterance: str, utterance: str,
@@ -1420,6 +1464,7 @@ class SeatDemoService:
_model_parse_source: str = "cloud_model", _model_parse_source: str = "cloud_model",
_allow_empty_control_result: bool = False, _allow_empty_control_result: bool = False,
_on_commands_validated: Callable[[dict[str, Any]], None] | None = None, _on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
_direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport) control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize # Creating a reusable scene is a configuration workflow. Synthesize
@@ -1444,6 +1489,28 @@ class SeatDemoService:
_model_parse_source, _model_parse_source,
_allow_empty_control_result, _allow_empty_control_result,
_on_commands_validated, _on_commands_validated,
_direct_can_batch,
)
if control_transport == WIRELESS_MQTT_TRANSPORT:
# The head unit owns both physical CAN channels in wireless mode.
# Local T2G/ZLG hardware access, firmware profiles and the local
# physical-bench lease are unrelated to MQTT relay execution.
return self._process_once(
utterance,
provider,
conversation_id,
execution_mode,
approval_token,
vehicle_id,
simulation_fault,
reasoning_effort,
control_transport,
_local_preview,
_excluded_control_signatures,
_model_parse_source,
_allow_empty_control_result,
_on_commands_validated,
_direct_can_batch,
) )
if hardware_access_bypass or self._driver_only_vehicle_execution_active( if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport control_transport
@@ -1464,6 +1531,7 @@ class SeatDemoService:
_model_parse_source, _model_parse_source,
_allow_empty_control_result, _allow_empty_control_result,
_on_commands_validated, _on_commands_validated,
_direct_can_batch,
) )
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id) authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve(): with self.hardware_execution.reserve():
@@ -1483,6 +1551,7 @@ class SeatDemoService:
_model_parse_source, _model_parse_source,
_allow_empty_control_result, _allow_empty_control_result,
_on_commands_validated, _on_commands_validated,
_direct_can_batch,
) )
def _process_once( def _process_once(
@@ -1501,6 +1570,7 @@ class SeatDemoService:
model_parse_source: str = "cloud_model", model_parse_source: str = "cloud_model",
allow_empty_control_result: bool = False, allow_empty_control_result: bool = False,
on_commands_validated: Callable[[dict[str, Any]], None] | None = None, on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport) control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip() utterance = utterance.strip()
@@ -1569,9 +1639,13 @@ class SeatDemoService:
vehicle_profile, vehicle_profile,
) )
context["mode_profiles"] = self.mode_profiles.public_modes(vehicle_profile.id) context["mode_profiles"] = self.mode_profiles.public_modes(vehicle_profile.id)
matched_modes = self.mode_profiles.match_text( matched_modes = (
utterance, []
vehicle_id=vehicle_profile.id, if direct_can_batch is not None
else self.mode_profiles.match_text(
utterance,
vehicle_id=vehicle_profile.id,
)
) )
traces: list[dict[str, Any]] = [] traces: list[dict[str, Any]] = []
result: dict[str, Any] = { result: dict[str, Any] = {
@@ -1614,6 +1688,9 @@ class SeatDemoService:
for item in context.get("presets", []) for item in context.get("presets", [])
], ],
"trace_events": traces, "trace_events": traces,
"can_commands": (
list(direct_can_batch[0]) if direct_can_batch is not None else []
),
} }
started = time.perf_counter() started = time.perf_counter()
@@ -1652,40 +1729,45 @@ class SeatDemoService:
result, result,
traces, traces,
) )
parsed, provider_used, provider_notice = self._interpret( if direct_can_batch is not None:
utterance, parsed = deepcopy(direct_can_batch[1])
provider, provider_used = "direct_can_control"
context, provider_notice = None
vehicle_profile,
reasoning_effort=reasoning_effort,
local_preview=local_preview,
model_parse_source=model_parse_source,
)
if self._is_dynamic_mode_stop_request(utterance):
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
else: else:
parsed = self._apply_mode_profiles( parsed, provider_used, provider_notice = self._interpret(
parsed, matched_modes, context, vehicle_profile utterance,
provider,
context,
vehicle_profile,
reasoning_effort=reasoning_effort,
local_preview=local_preview,
model_parse_source=model_parse_source,
)
if self._is_dynamic_mode_stop_request(utterance):
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
else:
parsed = self._apply_mode_profiles(
parsed, matched_modes, context, vehicle_profile
)
parsed = self._mark_control_parse_source(parsed, "local_rule")
relative_resolution = (
self.native_can_relative_resolver.resolve(utterance, context)
if self.native_can_relative_resolver.matches(utterance)
else None
)
parsed = merge_native_can_intent(
parsed,
utterance,
relative_actions=(
relative_resolution.actions if relative_resolution else None
),
relative_ambiguities=(
relative_resolution.ambiguities if relative_resolution else None
),
relative_claimed_controls=(
relative_resolution.claimed_controls if relative_resolution else None
),
) )
parsed = self._mark_control_parse_source(parsed, "local_rule")
relative_resolution = (
self.native_can_relative_resolver.resolve(utterance, context)
if self.native_can_relative_resolver.matches(utterance)
else None
)
parsed = merge_native_can_intent(
parsed,
utterance,
relative_actions=(
relative_resolution.actions if relative_resolution else None
),
relative_ambiguities=(
relative_resolution.ambiguities if relative_resolution else None
),
relative_claimed_controls=(
relative_resolution.claimed_controls if relative_resolution else None
),
)
if local_preview: if local_preview:
parsed["ambiguities"] = [] parsed["ambiguities"] = []
parsed["unresolved_spans"] = [] parsed["unresolved_spans"] = []
@@ -2136,7 +2218,8 @@ class SeatDemoService:
compile_started = time.perf_counter() compile_started = time.perf_counter()
try: try:
if hardware_request: if hardware_request:
self.hardware_settings.validate_function_config(function_config) if control_transport != WIRELESS_MQTT_TRANSPORT:
self.hardware_settings.validate_function_config(function_config)
command_plan = self.hardware_compiler.compile( command_plan = self.hardware_compiler.compile(
function_config, context["devices"] function_config, context["devices"]
) )
@@ -2199,7 +2282,11 @@ class SeatDemoService:
"safety_check", "safety_check",
"succeeded", "succeeded",
( (
( "无线 MQTT CAN0 座椅语义白名单与 seat-ota 报文白名单已通过;"
"本地 T2G 固件 profile、USB-CAN 与物理台架连接不参与校验。"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
else (
"硬件启用、授权令牌、固件 profile 与目标座椅配置门禁已通过;" "硬件启用、授权令牌、固件 profile 与目标座椅配置门禁已通过;"
if self.hardware_settings.approval_required if self.hardware_settings.approval_required
else "硬件启用、调试免令牌、固件 profile 与目标座椅配置门禁已通过;" else "硬件启用、调试免令牌、固件 profile 与目标座椅配置门禁已通过;"
@@ -2220,15 +2307,26 @@ class SeatDemoService:
simulation_fault if simulated_hardware_request else None simulation_fault if simulated_hardware_request else None
), ),
"position_calibration_gate": ( "position_calibration_gate": (
"runtime_can_preflight" if hardware_request else "not_applicable" "remote_seat_ota_feedback"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
else "runtime_can_preflight"
if hardware_request
else "not_applicable"
), ),
"mapping_status": ( "mapping_status": (
"protocol_hil_verified_combined_firmware_hil_pending" "mqtt_can0_seat_ota_whitelist"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
else "protocol_hil_verified_combined_firmware_hil_pending"
if hardware_request if hardware_request
else "draft" else "draft"
), ),
"firmware_profiles": ( "firmware_profiles": (
sorted(self.hardware_settings.profiles) if hardware_request else [] sorted(self.hardware_settings.profiles)
if hardware_request
and control_transport != WIRELESS_MQTT_TRANSPORT
else []
), ),
}, },
) )
@@ -2249,9 +2347,6 @@ class SeatDemoService:
command_plan, function_config, context["devices"] command_plan, function_config, context["devices"]
) )
if vehicle_safety_preflight is not None: if vehicle_safety_preflight is not None:
execution_report["spec"]["vehicle_safety_preflight"] = (
vehicle_safety_preflight
)
for item in execution_report["spec"].get("results", []): for item in execution_report["spec"].get("results", []):
item.setdefault("telemetry", {})[ item.setdefault("telemetry", {})[
"vehicle_safety_preflight" "vehicle_safety_preflight"
@@ -4045,10 +4140,15 @@ class SeatDemoService:
compile_started = time.perf_counter() compile_started = time.perf_counter()
plans: dict[str, dict[str, Any]] = {} plans: dict[str, dict[str, Any]] = {}
wireless_hardware_request = (
hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
)
for domain, config in configs.items(): for domain, config in configs.items():
if domain == "seat": if domain == "seat":
if hardware_request: if hardware_request:
self.hardware_settings.validate_function_config(config) if not wireless_hardware_request:
self.hardware_settings.validate_function_config(config)
plans[domain] = self.hardware_compiler.compile( plans[domain] = self.hardware_compiler.compile(
config, config,
context["devices"], context["devices"],
@@ -4225,13 +4325,10 @@ class SeatDemoService:
"labels": { "labels": {
"simulation": "mixed" if hardware_request else "true", "simulation": "mixed" if hardware_request else "true",
"seat_feedback": ( "seat_feedback": (
"wireless_mqtt_ch1" "wireless_mqtt_ch0"
if ( if wireless_hardware_request and "seat" in configs
hardware_request else "wireless_mqtt_ch1"
and control_transport == WIRELESS_MQTT_TRANSPORT if wireless_hardware_request and native_config_keys
and native_config_keys
and "seat" not in configs
)
else "physical_can_ch1" else "physical_can_ch1"
if hardware_request and native_config_keys and "seat" not in configs if hardware_request and native_config_keys and "seat" not in configs
else "physical_can" else "physical_can"
@@ -4276,13 +4373,9 @@ class SeatDemoService:
"execute", "execute",
"succeeded" if execution_succeeded and not report_issues else "failed", "succeeded" if execution_succeeded and not report_issues else "failed",
( (
"无线 MQTT 白名单控制帧已提交;车辆实际状态以状态话题回读为准。" "无线 MQTT CAN0/CAN1 白名单控制帧已提交;"
if ( "车辆实际状态以状态话题回读为准。"
hardware_request if wireless_hardware_request and execution_succeeded
and control_transport == WIRELESS_MQTT_TRANSPORT
and native_config_keys
and execution_succeeded
)
else "CH1 车身 CAN 动作已按目标字段物理回读闭环执行。" else "CH1 车身 CAN 动作已按目标字段物理回读闭环执行。"
if hardware_request and native_config_keys and execution_succeeded if hardware_request and native_config_keys and execution_succeeded
else "副驾座椅已按独立 T2G 物理 CAN 回读闭环执行。" else "副驾座椅已按独立 T2G 物理 CAN 回读闭环执行。"
@@ -23,6 +23,22 @@ FUZZY_UNMAPPED_CONTROL = re.compile(
r"(?P<text>(?:座椅|靠背|腿托|空调|车窗|风口|灯光?)" r"(?P<text>(?:座椅|靠背|腿托|空调|车窗|风口|灯光?)"
r"(?:调|弄|搞)(?:得|到)?(?:舒服|合适|好)(?:一点|点|些)?)" r"(?:调|弄|搞)(?:得|到)?(?:舒服|合适|好)(?:一点|点|些)?)"
) )
OUTLET_MODE_DISABLE = re.compile(
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?"
r"(?P<feature>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?|"
r"(?P<feature_after>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?"
r"(?:关闭|关掉|停止|取消)"
)
POSITIVE_OUTLET_MODE = re.compile(
r"(?:打开|开启|启用|切换到|切换为|改为|设为|设置为|调整为|调为)"
r"(?:空调)?(?:的)?(?:吹面|吹脸|吹脚|除霜)"
)
DRIVER_SEAT_THERMAL_MIDDLE = re.compile(
r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}"
r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
)
def _span(utterance: str, match: re.Match[str]) -> dict[str, Any]: def _span(utterance: str, match: re.Match[str]) -> dict[str, Any]:
@@ -114,6 +130,21 @@ def _remove_seat_target(parsed: dict[str, Any], target: str) -> None:
] ]
def _remove_capability(parsed: dict[str, Any], capability: str) -> None:
for key in (
"actions",
"domain_actions",
"native_can_actions",
"deferred_requests",
"model_requests",
):
parsed[key] = [
request
for request in parsed.get(key, [])
if request.get("capability") != capability
]
def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict[str, Any]: def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict[str, Any]:
"""Preserve negation and unresolved control language without producing CAN.""" """Preserve negation and unresolved control language without producing CAN."""
@@ -162,6 +193,100 @@ def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict
] ]
result["confidence"] = min(float(result.get("confidence", 0)), 0.8) result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
disabled_outlets = list(OUTLET_MODE_DISABLE.finditer(utterance))
if disabled_outlets and POSITIVE_OUTLET_MODE.search(utterance) is None:
_remove_capability(result, "climate.set_outlet_mode")
for match in disabled_outlets:
feature = match.group("feature") or match.group("feature_after")
constraints.append(
{
"type": "prohibit_action",
"domain": "climate",
"capability": "climate.set_outlet_mode",
"parameters": {"disabled_feature": feature},
"source_span": _span(utterance, match),
}
)
unresolved.append(
{
"text": match.group(0),
"reason": "outlet_feature_disable_requires_absolute_mode",
}
)
result["ambiguities"] = [
*result.get("ambiguities", []),
{
"field": "climate.outlet_mode",
"question": (
"当前CAN白名单只能设置完整出风模式;请明确改为吹脚、除霜等目标模式。"
),
"reason": "outlet_feature_disable_requires_absolute_mode",
},
]
result["decision_summary"] = [
*result.get("decision_summary", []),
"检测到关闭单一出风方向的请求;未将其错误转换为开启该出风模式。",
]
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
thermal_middle = DRIVER_SEAT_THERMAL_MIDDLE.search(utterance)
if thermal_middle is not None and not re.search(
r"副驾|副驾驶|乘客|右前", utterance
):
feature = "通风" if "通风" in thermal_middle.group(0) else "加热"
native_capability = (
"seat.set_driver_ventilation_native"
if feature == "通风"
else "seat.set_driver_heating_native"
)
canonical_capability = (
"seat.set_ventilation"
if feature == "通风"
else "seat.set_heating"
)
_remove_capability(result, native_capability)
_remove_capability(result, canonical_capability)
constraint = {
"type": "unsupported_discrete_level",
"domain": "seat",
"capability": native_capability,
"parameters": {"requested_level": "medium"},
"source_span": _span(utterance, thermal_middle),
}
if not any(
item.get("type") == constraint["type"]
and item.get("capability") == native_capability
and item.get("parameters") == constraint["parameters"]
for item in constraints
):
constraints.append(constraint)
unresolved.append(
{
"text": thermal_middle.group(0),
"reason": "driver_seat_thermal_middle_level_unsupported",
}
)
result["ambiguities"] = [
*result.get("ambiguities", []),
{
"field": (
"seat.driver.ventilation.level"
if feature == "通风"
else "seat.driver.heating.level"
),
"question": (
f"主驾座椅{feature}实车白名单只有低档和高档,"
"请明确选择低档或高档。"
),
"reason": "driver_seat_thermal_middle_level_unsupported",
},
]
result["decision_summary"] = [
*result.get("decision_summary", []),
f"识别到主驾座椅{feature}中档请求;实车协议不支持中档,未降级为其他档位。",
]
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
if not result.get("actions") and not result.get("domain_actions"): if not result.get("actions") and not result.get("domain_actions"):
for match in FUZZY_UNMAPPED_CONTROL.finditer(utterance): for match in FUZZY_UNMAPPED_CONTROL.finditer(utterance):
if not any(item.get("text") == match.group("text") for item in unresolved): if not any(item.get("text") == match.group("text") for item in unresolved):
@@ -43,18 +43,24 @@ MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
) )
MQTT_SAFETY_FEEDBACK_IDS = frozenset({0x342, 0x4EB}) 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})
# Frames accepted from the Android head unit are the seat-ota wire protocol. # CAN0 accepts both state formats currently emitted by the passenger-seat
# ``0x302`` is an internal compatibility frame synthesized below so the # stack. The production firmware publishes its authoritative classic-CAN
# existing seat-domain state reader can consume the four firmware axes; it is # status directly on 0x301..0x306/0x4FA. Older head-unit builds expose the
# deliberately not accepted as authoritative MQTT input. # read-only seat-ota request/response exchange on 0x6A0/0x6A2; that exchange is
MQTT_SEAT_WIRE_FEEDBACK_IDS = frozenset({0x6A0, 0x6A2}) # retained and translated to an internal 0x302 position frame below.
MQTT_SEAT_FEEDBACK_IDS = frozenset({0x302, 0x6A2}) MQTT_SEAT_NATIVE_FEEDBACK_IDS = frozenset(
{0x301, 0x302, 0x303, 0x304, 0x305, 0x306, 0x4FA}
)
MQTT_SEAT_WIRE_FEEDBACK_IDS = MQTT_SEAT_NATIVE_FEEDBACK_IDS | frozenset(
{0x6A0, 0x6A2}
)
MQTT_SEAT_FEEDBACK_IDS = MQTT_SEAT_NATIVE_FEEDBACK_IDS | frozenset({0x6A2})
MQTT_ACCEPTED_FEEDBACK_IDS = { MQTT_ACCEPTED_FEEDBACK_IDS = {
0: MQTT_SEAT_FEEDBACK_IDS, 0: MQTT_SEAT_FEEDBACK_IDS,
1: MQTT_VEHICLE_FEEDBACK_IDS, 1: MQTT_VEHICLE_FEEDBACK_IDS,
} }
MQTT_APPROVED_CONTROL_IDS = { MQTT_APPROVED_CONTROL_IDS = {
0: frozenset({0x6A0}), 0: frozenset({0x29E, 0x3CE, 0x6A0}),
1: frozenset({0x1DE, 0x32B, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}), 1: frozenset({0x1DE, 0x32B, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}),
} }
MQTT_OTA_SEAT_RUNTIME_OPCODES = frozenset({0x63, 0x64, 0x65, 0x66}) MQTT_OTA_SEAT_RUNTIME_OPCODES = frozenset({0x63, 0x64, 0x65, 0x66})
@@ -449,6 +455,39 @@ def _validate_ota_seat_runtime_control(data: bytes) -> None:
) )
def _validate_native_seat_thermal_control(frame: CanFrame) -> None:
"""Validate the two runtime thermal frames implemented by the CAN0 firmware."""
if len(frame.data) != 8:
raise CanGatewayError("CAN0 passenger-seat thermal control requires DLC 8")
if frame.arbitration_id == 0x3CE:
mode = (frame.data[0] >> 4) & 0x03
if mode not in {1, 2} or frame.data[0] & 0xCF or any(frame.data[1:]):
raise CanGatewayError("CAN0 0x3CE thermal mode payload is invalid")
return
if frame.arbitration_id != 0x29E:
raise CanGatewayError("CAN0 native thermal frame ID is invalid")
fan_on = frame.data[0] & 0x03
fan_mode = (frame.data[0] >> 2) & 0x03
heating_on = (frame.data[0] >> 4) & 0x03
fan_level = frame.data[2] & 0x0F
heating_level = (frame.data[2] >> 4) & 0x0F
if (
fan_on not in {1, 2}
or fan_mode != 1
or heating_on not in {1, 2}
or fan_level not in range(4)
or heating_level not in range(4)
or (fan_on == 1 and fan_level == 0)
or (fan_on == 2 and fan_level != 0)
or (heating_on == 1 and heating_level == 0)
or (heating_on == 2 and heating_level != 0)
or frame.data[1] != 0
or any(frame.data[3:])
):
raise CanGatewayError("CAN0 0x29E thermal control payload is invalid")
class MqttCanTransport: class MqttCanTransport:
"""One process-wide MQTT connection serving snapshots and executor buses.""" """One process-wide MQTT connection serving snapshots and executor buses."""
@@ -633,7 +672,7 @@ class MqttCanTransport:
discriminator = ( discriminator = (
frame.data[0] frame.data[0]
if frame.channel == 0 if frame.channel == 0
and frame.arbitration_id == 0x302 and frame.arbitration_id in {0x302, 0x303, 0x304}
and frame.data and frame.data
else 0 else 0
) )
@@ -751,7 +790,15 @@ class MqttCanTransport:
initial_frames = tuple( initial_frames = tuple(
frame frame
for (seen_channel, _frame_id, _discriminator), for (seen_channel, _frame_id, _discriminator),
(captured_monotonic, frame) in sorted(self._latest_bus_frames.items()) (captured_monotonic, frame) in sorted(
self._latest_bus_frames.items(),
key=lambda item: (
0 if item[0][1] == 0x306 else
1 if item[0][1] == 0x4FA else
2,
item[0],
),
)
if replay_latest if replay_latest
and seen_channel == channel and seen_channel == channel
and now - captured_monotonic <= self.freshness_s and now - captured_monotonic <= self.freshness_s
@@ -772,8 +819,10 @@ class MqttCanTransport:
if frame.channel == 0 and (frame.is_fd or frame.bitrate_switch): if frame.channel == 0 and (frame.is_fd or frame.bitrate_switch):
raise CanGatewayError("CAN0 passenger-seat control requires classic CAN") raise CanGatewayError("CAN0 passenger-seat control requires classic CAN")
_validate_headunit_command_frame(frame) _validate_headunit_command_frame(frame)
if frame.channel == 0: if frame.channel == 0 and frame.arbitration_id == 0x6A0:
_validate_ota_seat_runtime_control(frame.data) _validate_ota_seat_runtime_control(frame.data)
elif frame.channel == 0:
_validate_native_seat_thermal_control(frame)
with self._condition: with self._condition:
client = self._client client = self._client
state = self._state state = self._state
+47 -4
View File
@@ -367,7 +367,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
path, path,
) )
if ( if (
path not in {"/api/requests", "/api/requests/preview"} path not in {
"/api/requests",
"/api/requests/preview",
"/api/can/commands",
}
and not supplement_match and not supplement_match
): ):
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND) self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
@@ -402,7 +406,17 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if self.auth.config.enabled: if self.auth.config.enabled:
self.auth.api_requests.record(username) self.auth.api_requests.record(username)
payload = self._read_json_body() payload = self._read_json_body()
utterance = str(payload.get("utterance", "")) direct_can_commands = (
payload.get("commands") if path == "/api/can/commands" else None
)
utterance = (
";".join(
item if isinstance(item, str) else repr(item)
for item in direct_can_commands
)
if isinstance(direct_can_commands, list)
else str(payload.get("utterance", ""))
)
source = str(payload.get("client_type", "head_unit")) source = str(payload.get("client_type", "head_unit"))
if utterance: if utterance:
self.vehicle_display_sync.update_task( self.vehicle_display_sync.update_task(
@@ -451,7 +465,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
payload.get("control_transport", "wired_can") payload.get("control_transport", "wired_can")
), ),
} }
if "application/x-ndjson" in self.headers.get("Accept", ""): if (
path != "/api/can/commands"
and "application/x-ndjson" in self.headers.get("Accept", "")
):
self._stream_control_request( self._stream_control_request(
path, path,
supplement_match, supplement_match,
@@ -460,7 +477,33 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
source=source, source=source,
) )
return return
if path == "/api/requests/preview": if path == "/api/can/commands":
result = self.service.process_can_commands(
direct_can_commands,
conversation_id=(
str(payload["conversation_id"])
if payload.get("conversation_id")
else None
),
execution_mode=str(payload.get("execution_mode", "dry_run")),
approval_token=os.getenv(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
),
vehicle_id=(
str(payload["vehicle_id"])
if payload.get("vehicle_id")
else None
),
simulation_fault=str(
payload.get("simulation_fault", "normal")
),
hardware_client_id=hardware_client_id,
hardware_access_bypass=self._is_unproxied_loopback_request(),
control_transport=str(
payload.get("control_transport", "wired_can")
),
)
elif path == "/api/requests/preview":
result = self.service.preview_native_can(**common_arguments) result = self.service.preview_native_can(**common_arguments)
elif supplement_match: elif supplement_match:
result = self.service.supplement_native_can( result = self.service.supplement_native_can(
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -410,7 +410,7 @@
], ],
"rejection_cases": [ "rejection_cases": [
{"id": "SAFE-DRIVER-THERMAL-LEVEL", "utterance": "主驾座椅通风4档", "expected_status": "needs_confirmation"}, {"id": "SAFE-DRIVER-THERMAL-LEVEL", "utterance": "主驾座椅通风4档", "expected_status": "needs_confirmation"},
{"id": "SAFE-DRIVER-MASSAGE-GENERIC", "utterance": "打开主驾座椅按摩", "expected_status": "needs_confirmation"}, {"id": "SAFE-DRIVER-THERMAL-MIDDLE", "utterance": "主驾座椅通风中档", "expected_status": "needs_confirmation"},
{"id": "SAFE-DRIVER-MOTION", "utterance": "把驾驶位靠背调到50%", "expected_status": "needs_confirmation"}, {"id": "SAFE-DRIVER-MOTION", "utterance": "把驾驶位靠背调到50%", "expected_status": "needs_confirmation"},
{"id": "SAFE-DRIVER-MODE", "utterance": "主驾进入零重力模式", "expected_status": "needs_confirmation"}, {"id": "SAFE-DRIVER-MODE", "utterance": "主驾进入零重力模式", "expected_status": "needs_confirmation"},
{"id": "SAFE-FRONT-ROW", "utterance": "打开前排座椅加热", "expected_status": "needs_confirmation"}, {"id": "SAFE-FRONT-ROW", "utterance": "打开前排座椅加热", "expected_status": "needs_confirmation"},
@@ -0,0 +1,319 @@
from __future__ import annotations
import json
import os
import tempfile
import unittest
from collections import Counter
from pathlib import Path
from unittest.mock import patch
from cognitive_agent.application.mqtt_ota_seat_executor import MqttOtaSeatExecutor
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
CATALOG_PATH = (
REPOSITORY_ROOT
/ "tests"
/ "fixtures"
/ "natural_can_command_combination_suite.json"
)
HARDWARE_DISABLED_ENV = {
"COGNITIVE_AGENT_CH1_HIL_ENABLED": "false",
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
}
class NaturalCanCommandCombinationTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temporary_directory = tempfile.TemporaryDirectory()
temporary_root = Path(cls.temporary_directory.name)
memory_path = temporary_root / "driver-seat-memory.json"
memory_path.write_text(
json.dumps(
{
"version": 1,
"positions": {
str(slot): {
"payload_15": "15 00 00 00 00 00 00 00",
"payload_16": "16 00 00 00 00 00 00 00",
}
for slot in (1, 2, 3)
},
}
),
encoding="utf-8",
)
cls.environment = patch.dict(
os.environ,
{
**HARDWARE_DISABLED_ENV,
"COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH": str(memory_path),
},
clear=False,
)
cls.environment.start()
cls.service = SeatDemoService(REPOSITORY_ROOT)
cls.service.artifact_root = temporary_root / "artifacts"
cls.catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
@classmethod
def tearDownClass(cls) -> None:
cls.environment.stop()
cls.temporary_directory.cleanup()
@staticmethod
def _debug_commands(result: dict) -> list[str]:
commands: list[str] = []
for plan in result.get("command_plans", {}).values():
for command in plan.get("spec", {}).get("commands", []):
debug_command = command.get("signals", {}).get("debug_command")
if isinstance(debug_command, str):
commands.append(debug_command)
return commands
@staticmethod
def _can0_operations(result: dict, expected: list[dict]) -> Counter[str]:
capabilities = {item["capability"] for item in expected}
operations: list[dict] = []
for config in result.get("function_configs", {}).values():
for operation in config.get("spec", {}).get("operations", []):
if operation.get("capability") not in capabilities:
continue
target = operation.get("target", {})
operations.append(
{
"target": target.get("instance", target.get("zone")),
"capability": operation["capability"],
"parameters": operation.get("parameters", {}),
}
)
return Counter(
json.dumps(item, ensure_ascii=False, sort_keys=True)
for item in operations
)
@staticmethod
def _expected_can0_operations(expected: list[dict]) -> Counter[str]:
return Counter(
json.dumps(item, ensure_ascii=False, sort_keys=True)
for item in expected
)
@staticmethod
def _single_passenger_motion_operation(result: dict) -> dict:
operations = [
operation
for config in result.get("function_configs", {}).values()
for operation in config.get("spec", {}).get("operations", [])
if operation.get("capability")
in {
"seat.set_longitudinal_position",
"seat.set_cushion_tilt",
"seat.set_backrest_recline",
"seat.set_legrest_height",
}
]
if len(operations) != 1:
raise AssertionError(f"expected one passenger motion operation: {operations}")
return operations[0]
def test_catalog_metadata_and_all_expanded_cases(self) -> None:
cases = self.catalog["cases"]
self.assertEqual(
self.catalog["schema"],
"natural-can-command-combination-suite/v1",
)
self.assertEqual(self.catalog["summary"]["total_cases"], len(cases))
self.assertGreaterEqual(len(cases), 1000)
self.assertEqual(len({item["id"] for item in cases}), len(cases))
for index, case in enumerate(cases):
with self.subTest(case=case["id"], utterance=case["utterance"]):
result = self.service.process(
case["utterance"],
provider="local",
conversation_id=f"conv-natural-combination-{index:04d}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], case["expected_status"])
self.assertEqual(
self._debug_commands(result),
case["expected_commands"],
)
expected_can0 = case["expected_can0_operations"]
if expected_can0:
self.assertEqual(
self._can0_operations(result, expected_can0),
self._expected_can0_operations(expected_can0),
)
def test_can0_native_motion_and_dynamic_modes_combine_with_can1(self) -> None:
cases = (
(
"把副驾座椅水平位置调到35%,空调温度设置为24度,打开氛围灯",
["ac temp driver 24", "light ambient on"],
[{
"target": "passenger",
"capability": "seat.set_longitudinal_position",
"parameters": {"position_pct": 35},
}],
),
(
"副驾坐盆向上调整10%,关闭后排车窗,空调风量2档",
["window rear close", "ac fan 2"],
[{
"target": "passenger",
"capability": "seat.set_cushion_tilt",
"parameters": {"position_pct": 40},
}],
),
(
"开启木马模式并打开氛围灯",
["light ambient on"],
[{
"target": "passenger",
"capability": "seat.start_dynamic_mode",
"parameters": {"mode_id": 1},
}],
),
(
"开启动态摇篮模式,空调24度,打开遮阳帘",
["ac temp driver 24", "shade open"],
[{
"target": "passenger",
"capability": "seat.start_dynamic_mode",
"parameters": {"mode_id": 2},
}],
),
(
"副驾座椅前后调到20%、坐盆调到30%、靠背调到40%、腿托调到50%,空调风量3档,关闭全部车窗",
["ac fan 3", "window all close"],
[
{
"target": "passenger",
"capability": capability,
"parameters": {"position_pct": position},
}
for capability, position in (
("seat.set_longitudinal_position", 20),
("seat.set_cushion_tilt", 30),
("seat.set_backrest_recline", 40),
("seat.set_legrest_height", 50),
)
],
),
(
"副驾靠背向后移动15%,主驾温度设为25度,遮阳帘打开",
["ac temp driver 25", "shade open"],
[{
"target": "passenger",
"capability": "seat.set_backrest_recline",
"parameters": {"position_pct": 35},
}],
),
(
"副驾靠背往后转动10%,空调温度设置为24度",
["ac temp driver 24"],
[{
"target": "passenger",
"capability": "seat.set_backrest_recline",
"parameters": {"position_pct": 30},
}],
),
(
"副驾腿托设置为40%,关闭副驾车窗",
["window front-right close"],
[{
"target": "passenger",
"capability": "seat.set_legrest_height",
"parameters": {"position_pct": 40},
}],
),
)
for index, (utterance, can1_commands, can0_operations) in enumerate(cases):
with self.subTest(utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-can0-can1-targeted-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(self._debug_commands(result), can1_commands)
self.assertEqual(
self._can0_operations(result, can0_operations),
self._expected_can0_operations(can0_operations),
)
def test_passenger_relative_and_absolute_directional_commands_stay_distinct(self) -> None:
cases = (
("副驾座椅往上移动10%", "basin", "decrease", "up", 10),
("副驾座椅往上移动20%", "basin", "decrease", "up", 20),
("副驾往下移动10%", "basin", "increase", "down", 10),
("副驾往下移动20%", "basin", "increase", "down", 20),
("副驾座椅往前移动10%", "horizontal", "decrease", "forward", 10),
("副驾座椅往前移动20%", "horizontal", "decrease", "forward", 20),
("副驾往后移动10%", "horizontal", "increase", "backward", 10),
("副驾往后移动20%", "horizontal", "increase", "backward", 20),
("副驾座椅靠背往前移动10%", "backrest", "decrease", "forward", 10),
("副驾座椅靠背往前移动30%", "backrest", "decrease", "forward", 30),
("副驾靠背往后移动10%", "backrest", "increase", "backward", 10),
("副驾靠背往后移动30%", "backrest", "increase", "backward", 30),
("副驾推托往上移动20%", "legrest", "increase", "up", 20),
("副驾推托往上移动40%", "legrest", "increase", "up", 40),
("副驾推托往下移动20%", "legrest", "decrease", "down", 20),
("副驾推托往下移动40%", "legrest", "decrease", "down", 40),
)
for index, (utterance, axis, direction, command_direction, amount) in enumerate(
cases
):
with self.subTest(kind="relative", utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-passenger-relative-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "succeeded")
operation = self._single_passenger_motion_operation(result)
motion = operation["motion"]
self.assertEqual(motion["mode"], "relative")
self.assertEqual(motion["direction"], direction)
self.assertEqual(
motion.get(
"requested_delta_position_pct",
motion["delta_position_pct"],
),
amount,
)
self.assertTrue(
MqttOtaSeatExecutor._operation_debug_command(operation).startswith(
f"seat passenger {axis} relative {command_direction} "
)
)
absolute_utterance = utterance.replace("移动", "移动到")
with self.subTest(kind="absolute", utterance=absolute_utterance):
result = self.service.process(
absolute_utterance,
provider="local",
conversation_id=f"conv-passenger-absolute-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "succeeded")
operation = self._single_passenger_motion_operation(result)
self.assertNotIn("motion", operation)
self.assertEqual(operation["parameters"]["position_pct"], amount)
self.assertEqual(
MqttOtaSeatExecutor._operation_debug_command(operation),
f"seat passenger {axis} position {amount}",
)
if __name__ == "__main__":
unittest.main()
+182
View File
@@ -329,6 +329,188 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
24, 24,
) )
def test_disabling_one_outlet_feature_never_enables_that_mode(self) -> None:
for index, utterance in enumerate(
("关闭吹面出风口", "把吹面出风口关闭")
):
with self.subTest(utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-can-one-close-outlet-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "needs_confirmation")
self.assertEqual(self._debug_commands(result), [])
ambiguities = result["intent_ir"]["spec"]["ambiguities"]
self.assertEqual(
ambiguities[0]["reason"],
"outlet_feature_disable_requires_absolute_mode",
)
preview = self.service.preview_native_can(
"关闭吹面出风口",
provider="openai",
conversation_id="conv-can-one-close-outlet-preview",
execution_mode="dry_run",
)
self.assertFalse(preview["supplement_required"])
self.assertEqual(preview["unresolved_clauses"], [])
self.assertEqual(self._debug_commands(preview), [])
explicit_replacement = self.service.process(
"关闭吹面出风口,切换到吹脚模式",
provider="local",
conversation_id="conv-can-one-replace-outlet",
execution_mode="dry_run",
)
self.assertEqual(explicit_replacement["status"], "succeeded")
self.assertEqual(self._debug_commands(explicit_replacement), ["ac mode feet"])
def test_driver_seat_natural_language_levels_and_motion_aliases(self) -> None:
cases = (
("打开主驾座椅按摩", ["seat driver massage mode relax"]),
(
"主驾座椅按摩强度设置为最大",
["seat driver massage strength strong"],
),
(
"主驾座椅按摩强度最小",
["seat driver massage strength weak"],
),
(
"主驾座椅按摩强度调中",
["seat driver massage strength medium"],
),
("主驾座椅朝前挪一秒", ["seat driver horizontal forward 1000"]),
("主驾座椅后退两秒", ["seat driver horizontal backward 2000"]),
("主驾座椅椅背放倒", ["seat driver backrest backward 500"]),
("主驾座椅座垫放低", ["seat driver basin down 500"]),
("主驾座椅往上移动", ["seat driver height up 500"]),
("主驾座椅通风最低", ["seat driver vent low"]),
("主驾座椅通风最小", ["seat driver vent low"]),
("主驾座椅通风最高", ["seat driver vent high"]),
("主驾座椅通风最大", ["seat driver vent high"]),
("空调风量设置为最低", ["ac fan 1"]),
("空调风量设置为中等", ["ac fan 4"]),
("空调风量设置为最大", ["ac fan 7"]),
("氛围灯亮度最低", ["light ambient brightness 1"]),
("氛围灯亮度中等", ["light ambient brightness 3"]),
("氛围灯亮度最高", ["light ambient brightness 5"]),
(
"打开主驾座椅通风并关闭按摩",
["seat driver vent low", "seat driver massage off"],
),
(
"关闭主驾座椅通风并打开按摩",
["seat driver vent off", "seat driver massage mode relax"],
),
)
for index, (utterance, expected) in enumerate(cases):
with self.subTest(utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-can-one-seat-alias-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(self._debug_commands(result), expected)
stop_all = self.service.process(
"停止主驾座椅移动",
provider="local",
conversation_id="conv-can-one-seat-stop-all",
execution_mode="dry_run",
)
self.assertEqual(stop_all["status"], "succeeded")
self.assertEqual(
self._debug_commands(stop_all),
[
"seat driver horizontal stop",
"seat driver height stop",
"seat driver backrest stop",
"seat driver basin stop",
],
)
def test_unsupported_driver_seat_middle_thermal_level_is_explicit(self) -> None:
for index, feature in enumerate(("通风", "加热")):
utterance = f"主驾座椅{feature}调到中档"
with self.subTest(utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-can-one-seat-middle-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "needs_confirmation")
self.assertEqual(self._debug_commands(result), [])
self.assertIn(
"driver_seat_thermal_middle_level_unsupported",
{
item.get("reason")
for item in result["intent_ir"]["spec"]["ambiguities"]
},
)
preview = self.service.preview_native_can(
"主驾座椅通风设置为中等",
provider="openai",
conversation_id="conv-can-one-seat-middle-preview",
execution_mode="dry_run",
)
self.assertFalse(preview["supplement_required"])
self.assertEqual(preview["unresolved_clauses"], [])
self.assertEqual(self._debug_commands(preview), [])
def test_vehicle_position_aliases_stay_scoped_to_the_device_domain(self) -> None:
cases = (
("打开左前车窗", ["window front-left open"]),
("打开右前车窗", ["window front-right open"]),
("打开左后车窗", ["window rear-left open"]),
("打开右后车窗", ["window rear-right open"]),
("打开主驾车窗", ["window front-left open"]),
("打开副驾车窗", ["window front-right open"]),
("打开左前座椅通风", ["seat driver vent low"]),
("打开主驾座椅通风", ["seat driver vent low"]),
("主驾空调温度设置为24度", ["ac temp driver 24"]),
("副驾空调温度设置为24度", ["ac temp passenger 24"]),
)
for index, (utterance, expected) in enumerate(cases):
with self.subTest(utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-can-position-alias-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(self._debug_commands(result), expected)
for index, utterance in enumerate(
("打开右前座椅通风", "打开副驾座椅通风")
):
with self.subTest(utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-can-passenger-alias-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "succeeded")
operations = [
operation
for config in result.get("function_configs", {}).values()
for operation in config.get("spec", {}).get("operations", [])
]
self.assertNotIn(
"seat.set_driver_ventilation_native",
{item["capability"] for item in operations},
)
self.assertEqual(operations[0]["capability"], "seat.set_ventilation")
self.assertEqual(operations[0]["target"]["zone"], "front_right")
def test_fractional_values_are_not_silently_truncated(self) -> None: def test_fractional_values_are_not_silently_truncated(self) -> None:
utterances = ( utterances = (
"空调温度设为24.5度", "空调温度设为24.5度",
+232
View File
@@ -0,0 +1,232 @@
from __future__ import annotations
import os
from pathlib import Path
import tempfile
from unittest.mock import patch
import pytest
from cognitive_agent.application.direct_can_control import build_direct_can_intent
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
def test_driver_device_batch_keeps_each_exact_control_independent() -> None:
commands = [
"seat driver vent low",
"seat driver heat off",
"seat driver massage mode stretch",
"seat driver massage strength strong",
]
normalized, parsed = build_direct_can_intent(commands)
assert normalized == commands
assert [item["capability"] for item in parsed["native_can_actions"]] == [
"seat.set_driver_ventilation_native",
"seat.set_driver_heating_native",
"seat.set_driver_massage_native",
"seat.set_driver_massage_native",
]
assert [item["parameters"] for item in parsed["native_can_actions"]] == [
{"state": "on", "level": 2},
{"state": "off"},
{"action": "mode", "mode": "stretch"},
{"action": "strength", "strength": 3},
]
assert {
item["parse_source"] for item in parsed["native_can_actions"]
} == {"direct_control"}
def test_passenger_device_batch_builds_can0_position_action() -> None:
normalized, parsed = build_direct_can_intent(
["seat passenger backrest position 36"]
)
assert normalized == ["seat passenger backrest position 36"]
assert parsed["native_can_actions"] == []
assert parsed["actions"] == [
{
"target": "passenger",
"action_type": "position",
"axis": "backrest_recline",
"position_pct": 36,
"parse_source": "direct_control",
"source_span": "seat passenger backrest position 36",
}
]
def test_passenger_can0_canonical_absolute_relative_thermal_and_dynamic() -> None:
context = {
"devices": {
"passenger": {
"position": {
"longitudinal": {"position_pct": 40},
"cushion_tilt": {"position_pct": 50},
"backrest_recline": {"position_pct": 60},
"legrest_height": {"position_pct": 70},
}
}
}
}
commands = [
"seat passenger horizontal position 35",
"seat passenger basin relative up 10",
"seat passenger vent medium",
"seat passenger dynamic rocking-horse",
"seat passenger dynamic stop",
]
normalized, parsed = build_direct_can_intent(commands, context)
assert normalized == commands
assert parsed["detected_domains"] == ["seat"]
assert parsed["actions"][0]["axis"] == "longitudinal"
assert parsed["actions"][0]["position_pct"] == 35
assert parsed["actions"][1]["axis"] == "cushion_tilt"
assert parsed["actions"][1]["position_pct"] == 40
assert parsed["actions"][1]["motion_intent"] == {
"mode": "relative",
"direction": "decrease",
"start_position_pct": 50,
"delta_position_pct": 10,
}
assert parsed["actions"][2]["function"] == "ventilation"
assert parsed["actions"][2]["requested_level"] == 2
assert parsed["actions"][3]["action_type"] == "dynamic_mode"
assert parsed["actions"][3]["mode_id"] == 1
assert parsed["actions"][4]["action_type"] == "dynamic_mode_stop"
def test_passenger_can0_relative_command_requires_current_position() -> None:
with pytest.raises(ValueError, match="requires a valid current"):
build_direct_can_intent(
["seat passenger horizontal relative forward 10"],
{"devices": {"passenger": {"position": {}}}},
)
@pytest.mark.parametrize(
"commands",
(
[],
["seat driver vent medium"],
["seat passenger height position 101"],
["headlight low-beam on"],
),
)
def test_direct_batch_rejects_non_whitelisted_commands(commands: list[str]) -> None:
with pytest.raises(ValueError):
build_direct_can_intent(commands)
def test_service_direct_batch_bypasses_natural_language_interpreter() -> None:
with tempfile.TemporaryDirectory() as directory, patch.dict(
os.environ,
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
},
clear=False,
):
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = Path(directory) / "artifacts"
with patch.object(
service,
"_interpret",
side_effect=AssertionError("natural-language parsing must not run"),
):
result = service.process_can_commands(
[
"ac on",
"ac temp driver 24",
"ac fan 2",
"ac cycle internal",
],
execution_mode="dry_run",
control_transport="wireless_mqtt",
)
assert result["status"] == "succeeded"
assert result["provider_used"] == "direct_can_control"
assert result["control_transport"] == "wireless_mqtt"
assert result["can_commands"] == [
"ac on",
"ac temp driver 24",
"ac fan 2",
"ac cycle internal",
]
operations = [
operation
for configuration in result["function_configs"].values()
for operation in configuration["spec"]["operations"]
]
assert [item["capability"] for item in operations] == [
"climate.set_power",
"climate.set_temperature",
"climate.set_fan_level",
"climate.set_cycle_mode",
]
assert {item["parse_source"] for item in operations} == {"direct_control"}
def test_service_direct_batch_combines_can0_and_can1_semantics() -> None:
with tempfile.TemporaryDirectory() as directory, patch.dict(
os.environ,
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
},
clear=False,
):
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = Path(directory) / "artifacts"
result = service.process_can_commands(
[
"seat passenger basin relative up 10",
"seat passenger vent medium",
"seat passenger dynamic rocking-horse",
"ac fan 2",
"light ambient on",
],
conversation_id="conv-direct-can0-can1",
execution_mode="dry_run",
)
assert result["status"] == "succeeded"
assert result["can_commands"] == [
"seat passenger basin relative up 10",
"seat passenger vent medium",
"seat passenger dynamic rocking-horse",
"ac fan 2",
"light ambient on",
]
operations = [
operation
for configuration in result["function_configs"].values()
for operation in configuration["spec"]["operations"]
]
by_capability = {item["capability"]: item for item in operations}
assert by_capability["seat.set_cushion_tilt"]["parameters"] == {
"position_pct": 40
}
assert by_capability["seat.set_cushion_tilt"]["motion"] == {
"mode": "relative",
"direction": "decrease",
"start_position_pct": 50,
"delta_position_pct": 10,
}
assert by_capability["seat.start_dynamic_mode"]["parameters"] == {
"mode_id": 1
}
assert by_capability["seat.set_ventilation"]["parameters"] == {
"state": "on",
"level": 2,
}
assert by_capability["climate.set_fan_level"]["parameters"] == {"level": 2}
assert by_capability["ambient_light.set_power"]["parameters"] == {
"state": "on"
}
assert {item["parse_source"] for item in operations} == {"direct_control"}
+4 -1
View File
@@ -190,6 +190,10 @@ class DriverSeatNativeCanTests(unittest.TestCase):
"seat.set_driver_massage_native", "seat.set_driver_massage_native",
{"action": "mode", "mode": "wave"}, {"action": "mode", "mode": "wave"},
), ),
"打开主驾座椅按摩": (
"seat.set_driver_massage_native",
{"action": "mode", "mode": "relax"},
),
"主驾座椅按摩强度调高": ( "主驾座椅按摩强度调高": (
"seat.set_driver_massage_native", "seat.set_driver_massage_native",
{"action": "strength", "strength": 3}, {"action": "strength", "strength": 3},
@@ -239,7 +243,6 @@ class DriverSeatNativeCanTests(unittest.TestCase):
"打开右前座椅加热", "打开右前座椅加热",
"打开主驾和副驾座椅通风", "打开主驾和副驾座椅通风",
"seat driver vent low, seat passenger vent low", "seat driver vent low, seat passenger vent low",
"打开主驾座椅按摩",
"主驾座椅向前移动50毫秒", "主驾座椅向前移动50毫秒",
"seat driver height forward 500", "seat driver height forward 500",
) )
@@ -306,7 +306,7 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
self.assertIn("const driverOnlyCh1 = isDriverOnlyCh1Runtime()", submit_source) self.assertIn("const driverOnlyCh1 = isDriverOnlyCh1Runtime()", submit_source)
self.assertIn("&& !driverOnlyCh1", submit_source) self.assertIn("&& !driverOnlyCh1", submit_source)
self.assertIn( self.assertIn(
'state.executionMode === "hardware" && !driverOnlyCh1', '&& state.controlTransport !== "wireless_mqtt"',
submit_source, submit_source,
) )
startup_source = self.app_source[ startup_source = self.app_source[
+242 -3
View File
@@ -203,7 +203,7 @@ class MqttCanPayloadTests(unittest.TestCase):
self.assertEqual(frame.arbitration_id, 0x42E) self.assertEqual(frame.arbitration_id, 0x42E)
self.assertTrue(frame.is_fd) self.assertTrue(frame.is_fd)
def test_ignores_legacy_can0_state_outside_seat_ota_wire_protocol(self) -> None: def test_accepts_native_passenger_seat_firmware_status(self) -> None:
frames = parse_mqtt_can_payload( frames = parse_mqtt_can_payload(
json.dumps( json.dumps(
{ {
@@ -220,7 +220,9 @@ class MqttCanPayloadTests(unittest.TestCase):
} }
) )
) )
self.assertEqual(frames, []) self.assertEqual(len(frames), 1)
self.assertEqual(frames[0].channel, 0)
self.assertEqual(frames[0].arbitration_id, 0x302)
def test_translates_seat_ota_info_responses_into_four_position_states(self) -> None: def test_translates_seat_ota_info_responses_into_four_position_states(self) -> None:
values = { values = {
@@ -317,7 +319,7 @@ class MqttCanPayloadTests(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
[(frame.channel, frame.arbitration_id) for frame in frames], [(frame.channel, frame.arbitration_id) for frame in frames],
[(1, 0x342)], [(0, 0x302), (1, 0x342)],
) )
def test_rejects_decoded_state_invalid_channels_and_channel_conflicts(self) -> None: def test_rejects_decoded_state_invalid_channels_and_channel_conflicts(self) -> None:
@@ -435,6 +437,76 @@ class MqttCanTransportTests(unittest.TestCase):
self.assertLess(time.perf_counter() - started, 0.05) self.assertLess(time.perf_counter() - started, 0.05)
self.assertEqual(snapshot.observed_ids, frozenset({0x388})) self.assertEqual(snapshot.observed_ids, frozenset({0x388}))
def test_native_can0_status_maps_to_passenger_frontend_state(self) -> None:
self.client.emit(
"n7/n7-headunit/can/state",
{
"schema": "n7-can-signals/v1",
"signals": [
{
"channel": "can0",
"canId": "0x302",
"frameType": "classic",
"bytes": [axis, 0x26, 0x08, 0, 0, value & 0xFF,
(value >> 8) & 0xFF, 0],
}
for axis, value in enumerate((1000, 2500, 5000, 9000))
] + [
{
"channel": "can0",
"canId": "0x4FA",
"frameType": "classic",
"bytes": [0x22, 0, 0, 0, 0, 0, 0, 0],
},
{
"channel": "can0",
"canId": "0x306",
"frameType": "classic",
"bytes": [1, 2, 3, 0x0F, 2, 0, 100, 0],
},
],
},
)
service = SeatDemoService(REPOSITORY_ROOT)
service._vehicle_mqtt_transport = self.transport
service.vehicle_mqtt_settings = SimpleNamespace(state_configured=True)
payload = service._synchronize_hardware_state_once(
"conv-native-can0-state",
control_transport="wireless_mqtt",
)
passenger = payload["actuator_state"]["passenger"]
self.assertEqual(
{
axis: value["position_pct"]
for axis, value in passenger["position"].items()
if axis in {
"longitudinal",
"cushion_tilt",
"backrest_recline",
"legrest_height",
}
},
{
"longitudinal": 10,
"cushion_tilt": 25,
"backrest_recline": 50,
"legrest_height": 90,
},
)
self.assertEqual(passenger["ventilation"]["level"], 1)
self.assertEqual(passenger["heating"]["level"], 3)
self.assertEqual(passenger["dynamic_mode"]["mode_id"], 1)
self.assertEqual(passenger["dynamic_mode"]["state"], "running")
self.assertTrue(
all(
item["feedback_source"] == "wireless_mqtt_ch0"
for item in passenger["position"].values()
if item.get("position_valid") is not None
)
)
def test_vehicle_envelope_four_frames_decode_from_raw_can1_bytes(self) -> None: def test_vehicle_envelope_four_frames_decode_from_raw_can1_bytes(self) -> None:
self.client.emit( self.client.emit(
"n7/n7-headunit/can/state", "n7/n7-headunit/can/state",
@@ -758,6 +830,158 @@ class MqttCanTransportTests(unittest.TestCase):
) )
) )
def test_wireless_hardware_can1_service_skips_local_hardware_connection(self) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
service._vehicle_mqtt_transport = self.transport
service.vehicle_mqtt_settings = SimpleNamespace(
enabled=True,
configured=True,
feedback_timeout_ms=1,
authorize=lambda _token: None,
)
validated_events: list[dict[str, object]] = []
with tempfile.TemporaryDirectory() as temporary_directory:
service.artifact_root = Path(temporary_directory)
with (
patch.object(
service.hardware_execution,
"reserve",
side_effect=AssertionError(
"wireless CAN1 must not reserve the local physical bench"
),
),
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
):
result = service.process(
"空调温度设置为24度",
provider="local",
conversation_id="conv-wireless-can1-no-local-hardware",
execution_mode="hardware",
control_transport="wireless_mqtt",
_on_commands_validated=validated_events.append,
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(list(result["command_plans"]), ["native_climate"])
self.assertEqual(len(validated_events), 1)
self.assertEqual(
list(validated_events[0]["command_plans"]),
["native_climate"],
)
payloads = [json.loads(item[1]) for item in self.client.published]
self.assertEqual(len(payloads), 3)
self.assertTrue(all(item["channel"] == "can1" for item in payloads))
self.assertTrue(all(item["command"] == "ac temp driver 24" for item in payloads))
def test_wireless_hardware_combines_can0_and_can1_without_local_profile(self) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
service._vehicle_mqtt_transport = self.transport
service.vehicle_mqtt_settings = SimpleNamespace(
enabled=True,
configured=True,
feedback_timeout_ms=1,
authorize=lambda _token: None,
)
validated_events: list[dict[str, object]] = []
seat_execution: dict[str, object] = {}
def execute_seat(
plan: dict[str, object],
config: dict[str, object],
_state: dict[str, object],
) -> dict[str, object]:
seat_execution["plan"] = plan
seat_execution["config"] = config
operation = config["spec"]["operations"][0] # type: ignore[index]
request_id = config["metadata"]["request_id"] # type: ignore[index]
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "ExecutionReport",
"metadata": {
"id": f"report-{request_id}",
"name": "wireless CAN0 test report",
"version": "1.0.0",
"request_id": request_id,
"labels": {
"simulation": "false",
"seat_feedback": "wireless_mqtt_ch0",
},
},
"spec": {
"mode": "hil",
"status": "succeeded",
"started_at": "2026-08-25T00:00:00Z",
"finished_at": "2026-08-25T00:00:00Z",
"results": [
{
"ref": operation["id"],
"domain": "seat",
"status": "succeeded",
"message": "wireless CAN0 command published",
"telemetry": {
"effective_position_pct": 35,
"position_valid": True,
"simulated": False,
},
}
],
},
}
with tempfile.TemporaryDirectory() as temporary_directory:
service.artifact_root = Path(temporary_directory)
with (
patch.object(
type(service.hardware_settings),
"validate_function_config",
side_effect=AssertionError(
"wireless mixed CAN0/CAN1 must not check combined profile"
),
),
patch.object(
service.hardware_execution,
"reserve",
side_effect=AssertionError(
"wireless mixed CAN0/CAN1 must not reserve local hardware"
),
),
patch.object(
service,
"_new_hardware_executor",
return_value=SimpleNamespace(execute=execute_seat),
) as executor_factory,
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
):
result = service.process(
"把副驾座椅水平位置调到35%,空调温度设置为24度",
provider="local",
conversation_id="conv-wireless-can0-can1-no-local-hardware",
execution_mode="hardware",
control_transport="wireless_mqtt",
_on_commands_validated=validated_events.append,
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(
set(result["command_plans"]),
{"seat", "native_climate"},
)
self.assertEqual(len(validated_events), 1)
self.assertEqual(
set(validated_events[0]["command_plans"]),
{"seat", "native_climate"},
)
self.assertEqual(
seat_execution["plan"]["spec"]["commands"][0]["message"], # type: ignore[index]
"T2G_Seat_Move_Command_300",
)
self.assertTrue(executor_factory.call_args.kwargs["remote_transport"])
payloads = [json.loads(item[1]) for item in self.client.published]
self.assertEqual(len(payloads), 3)
self.assertTrue(all(item["channel"] == "can1" for item in payloads))
self.assertTrue(all(item["command"] == "ac temp driver 24" for item in payloads))
def test_wireless_fixed_controls_and_shade_preset_never_wait_for_state( def test_wireless_fixed_controls_and_shade_preset_never_wait_for_state(
self, self,
) -> None: ) -> None:
@@ -935,6 +1159,21 @@ class MqttCanTransportTests(unittest.TestCase):
self.assertEqual(payload["command"], "") self.assertEqual(payload["command"], "")
self.assertEqual(payload["frameType"], "classic") self.assertEqual(payload["frameType"], "classic")
self.assertFalse(payload["brs"]) self.assertFalse(payload["brs"])
bus.set_debug_command("seat passenger heat low")
bus.send(0x3CE, bytes.fromhex("10 00 00 00 00 00 00 00"))
bus.send(0x29E, bytes.fromhex("16 00 10 00 00 00 00 00"))
thermal_payloads = [
json.loads(item[1]) for item in self.client.published[-2:]
]
self.assertEqual(
[item["canId"] for item in thermal_payloads],
["0x3CE", "0x29E"],
)
self.assertTrue(
all(item["command"] == "seat passenger heat low" for item in thermal_payloads)
)
with self.assertRaisesRegex(Exception, "0x29E thermal control payload"):
bus.send(0x29E, bytes(8))
with self.assertRaisesRegex(Exception, "approved CAN0 control set"): with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
bus.send(0x431, bytes(8)) bus.send(0x431, bytes(8))
with self.assertRaisesRegex(Exception, "requires classic CAN"): with self.assertRaisesRegex(Exception, "requires classic CAN"):
+149 -8
View File
@@ -2,8 +2,12 @@ from __future__ import annotations
import unittest import unittest
from collections import deque from collections import deque
from pathlib import Path
import tempfile
from types import SimpleNamespace 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 ( from cognitive_agent.application.mqtt_ota_seat_executor import (
MqttOtaSeatExecutor, MqttOtaSeatExecutor,
) )
@@ -11,6 +15,9 @@ from cognitive_agent.infrastructure.can.gateway import CanFrame
from seat_domain import parse_local_intent from seat_domain import parse_local_intent
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
def _axis_frame(axis: int, value_x100: int, *, valid: bool = True) -> CanFrame: def _axis_frame(axis: int, value_x100: int, *, valid: bool = True) -> CanFrame:
flags = (1 << 3) if valid else 0 flags = (1 << 3) if valid else 0
return CanFrame( return CanFrame(
@@ -57,6 +64,8 @@ class _FakeOtaBus:
bitrate_switch=bitrate_switch, bitrate_switch=bitrate_switch,
) )
self.sent.append(frame) self.sent.append(frame)
if arbitration_id != 0x6A0:
return frame
opcode = data[2] opcode = data[2]
job_state = ( job_state = (
1 | (4 << 4) | (9 << 8) 1 | (4 << 4) | (9 << 8)
@@ -87,6 +96,30 @@ class _FakeOtaBus:
class _StatusExecutor: 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",
}
matrix = _Matrix()
def read_physical_state(self) -> dict[str, object]: def read_physical_state(self) -> dict[str, object]:
return {"position": {}, "complete": False} return {"position": {}, "complete": False}
@@ -97,6 +130,37 @@ class _StatusExecutor:
) -> dict[str, object]: ) -> dict[str, object]:
return {"position": {}, "complete": True, "missing": []} 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 _function_config(capability: str, parameters: dict[str, object]) -> dict[str, object]: def _function_config(capability: str, parameters: dict[str, object]) -> dict[str, object]:
return { return {
@@ -115,6 +179,65 @@ def _function_config(capability: str, parameters: dict[str, object]) -> dict[str
class MqttOtaSeatExecutorTests(unittest.TestCase): 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(),
lambda: bus,
)
service = SeatDemoService(REPOSITORY_ROOT)
service.vehicle_mqtt_settings = SimpleNamespace(
enabled=True,
configured=True,
authorize=lambda _token: None,
)
validated_events: list[dict[str, object]] = []
with tempfile.TemporaryDirectory() as temporary_directory:
service.artifact_root = Path(temporary_directory)
with (
patch.object(
type(service.hardware_settings),
"validate_function_config",
side_effect=AssertionError(
"wireless CAN0 must not check the local combined profile"
),
),
patch.object(
service.hardware_execution,
"reserve",
side_effect=AssertionError(
"wireless CAN0 must not reserve the local physical bench"
),
),
patch.object(
service,
"_new_hardware_executor",
return_value=executor,
) as executor_factory,
):
result = service.process(
"把副驾座椅水平位置调到35%",
provider="local",
conversation_id="conv-wireless-can0-no-local-hardware",
execution_mode="hardware",
control_transport="wireless_mqtt",
_on_commands_validated=validated_events.append,
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(list(result["command_plans"]), ["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"])
def test_seat_height_language_maps_to_internal_axis_one_capability(self) -> None: def test_seat_height_language_maps_to_internal_axis_one_capability(self) -> None:
parsed = parse_local_intent("副驾座椅高度调到60%") parsed = parse_local_intent("副驾座椅高度调到60%")
@@ -161,7 +284,7 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 500) self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 500)
self.assertEqual( self.assertEqual(
bus.debug_commands, bus.debug_commands,
["seat passenger longitudinal position 50"], ["seat passenger horizontal position 50"],
) )
self.assertEqual( self.assertEqual(
report["spec"]["results"][0]["telemetry"]["effective_position_pct"], report["spec"]["results"][0]["telemetry"]["effective_position_pct"],
@@ -203,8 +326,10 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
self.assertNotIn(0x61, [frame.data[2] for frame in bus.sent]) self.assertNotIn(0x61, [frame.data[2] for frame in bus.sent])
self.assertNotIn(0x62, [frame.data[2] for frame in bus.sent]) self.assertNotIn(0x62, [frame.data[2] for frame in bus.sent])
def test_wireless_thermal_command_fails_before_publish(self) -> None: def test_wireless_thermal_command_uses_native_firmware_frames(self) -> None:
bus = _FakeOtaBus([]) bus = _FakeOtaBus(
[CanFrame(0x4FA, bytes((0x11, 0, 0, 0, 0, 0, 0, 0)), channel=0)]
)
executor = MqttOtaSeatExecutor( # type: ignore[arg-type] executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
_StatusExecutor(), _StatusExecutor(),
lambda: bus, lambda: bus,
@@ -212,10 +337,21 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
plan = { plan = {
"spec": { "spec": {
"commands": [ "commands": [
{
"operation_ref": "operation-1",
"message": "T2G_Seat_Thermal_Mode_3CE",
"signals": {"Driver_Seat_Thermal_Mode": 1},
},
{ {
"operation_ref": "operation-1", "operation_ref": "operation-1",
"message": "T2G_Seat_Thermal_Control_29E", "message": "T2G_Seat_Thermal_Control_29E",
"signals": {}, "signals": {
"Driver_Seat_Ventilation_On": 2,
"Driver_Seat_Ventilation_Mode": 1,
"Driver_Seat_Heating_On": 1,
"Driver_Seat_Ventilation_Gear": 0,
"Driver_Seat_Heating_Gear": 1,
},
} }
] ]
} }
@@ -226,9 +362,14 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
_function_config("seat.set_heating", {"state": "on", "level": 1}), _function_config("seat.set_heating", {"state": "on", "level": 1}),
) )
self.assertEqual(report["spec"]["status"], "failed") self.assertEqual(report["spec"]["status"], "succeeded")
self.assertIn("does not provide wireless thermal control", report["spec"]["results"][0]["message"]) self.assertEqual([frame.arbitration_id for frame in bus.sent], [0x3CE, 0x29E])
self.assertEqual(bus.sent, []) 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"))
self.assertEqual(
bus.debug_commands,
["seat passenger heat low", "seat passenger heat low"],
)
def test_dynamic_semantic_modes_use_seat_ota_firmware_selectors(self) -> None: def test_dynamic_semantic_modes_use_seat_ota_firmware_selectors(self) -> None:
for semantic_mode, firmware_selector, mode_name in ( for semantic_mode, firmware_selector, mode_name in (
@@ -267,7 +408,7 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
self.assertEqual( self.assertEqual(
bus.debug_commands, bus.debug_commands,
[ [
"seat passenger dynamic start " "seat passenger dynamic "
+ ("rocking-horse" if semantic_mode == 1 else "cradle") + ("rocking-horse" if semantic_mode == 1 else "cradle")
], ],
) )
@@ -165,8 +165,10 @@ def test_vehicle_device_status_cards_prioritize_seats_and_expose_manual_controls
assert 'data-device-control-toggle="${device.id}"' in console assert 'data-device-control-toggle="${device.id}"' in console
assert 'data-device-control-apply="${device.id}"' in console assert 'data-device-control-apply="${device.id}"' in console
assert 'new CustomEvent("cognitive-agent:vehicle-control-request"' in console assert 'new CustomEvent("cognitive-agent:vehicle-can-control-request"' in console
assert "window.CognitiveAgentConsole?.executeUtterance?.(utterance" in console assert "window.CognitiveAgentConsole?.executeCanCommands" in console
assert "device.commands(values).filter(Boolean)" in console
assert 'controllable: false' in console
assert '.v2-device-control-row input[type="range"]' in styles assert '.v2-device-control-row input[type="range"]' in styles
assert ".v2-device-control-select" in styles assert ".v2-device-control-select" in styles
assert ".v2-device-control-apply" in styles assert ".v2-device-control-apply" in styles
@@ -229,7 +231,7 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
"light ambient brightness ${level}", "light ambient brightness ${level}",
"shade position ${position}", "shade position ${position}",
"seat passenger ${axis[1]} position ${position}", "seat passenger ${axis[1]} position ${position}",
"seat passenger dynamic start ${dynamic.name}", "seat passenger dynamic ${dynamic.name}",
"seat passenger dynamic stop", "seat passenger dynamic stop",
"otaCan0Frame(0x64, axis[2], targetPermille)", "otaCan0Frame(0x64, axis[2], targetPermille)",
"otaCan0Frame(0x66, dynamic.selector, 300)", "otaCan0Frame(0x66, dynamic.selector, 300)",
+2 -1
View File
@@ -185,8 +185,9 @@ def test_frontend_two_stage_contract_and_source_badges() -> None:
assert '"cognitive-agent:validated-can-commands"' in app assert '"cognitive-agent:validated-can-commands"' in app
assert "本地规则命令已执行;云端补充失败" in app assert "本地规则命令已执行;云端补充失败" in app
assert 'class="v2-can-command-source"' in console assert 'class="v2-can-command-source"' in console
assert 'item.parseSource === "direct_control" ? "设备控件"' in console
assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console
assert '"cognitive-agent:validated-can-commands"' in console assert '"cognitive-agent:validated-can-commands"' in console
assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles
assert 'path not in {"/api/requests", "/api/requests/preview"}' in server assert '"/api/can/commands"' in server
assert '"type": "validated_commands"' in server assert '"type": "validated_commands"' in server
+847
View File
@@ -0,0 +1,847 @@
#!/usr/bin/env python3
"""Generate the expanded natural-language to CAN command test catalog."""
from __future__ import annotations
import csv
from collections import Counter
from itertools import combinations
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
JSON_PATH = ROOT / "tests/fixtures/natural_can_command_combination_suite.json"
CSV_PATH = ROOT / "docs/testing/自然语言转CAN组合测试用例.csv"
DOC_PATH = ROOT / "docs/testing/自然语言转CAN组合测试说明.md"
def can0_operation(
capability: str,
parameters: dict[str, Any],
target: str = "passenger",
) -> dict[str, Any]:
return {
"target": target,
"capability": capability,
"parameters": parameters,
}
def build_cases() -> list[dict[str, Any]]:
cases: list[dict[str, Any]] = []
def add(
case_id: str,
utterance: str,
*,
domains: tuple[str, ...],
channels: tuple[str, ...] = ("can1",),
commands: tuple[str, ...] = (),
can0_operations: tuple[dict[str, Any], ...] = (),
kind: str = "atomic",
status: str = "succeeded",
sources: tuple[str, ...] = (),
) -> dict[str, Any]:
case = {
"id": case_id,
"kind": kind,
"channels": list(channels),
"domains": list(domains),
"utterance": utterance,
"expected_status": status,
"expected_commands": list(commands),
"expected_can0_operations": list(can0_operations),
}
if sources:
case["source_case_ids"] = list(sources)
cases.append(case)
return case
add(
"CAN1-DOOR-REAR-LOCK",
"后排车门上锁",
domains=("door",),
commands=("door rear lock",),
)
add(
"CAN1-DOOR-REAR-UNLOCK",
"后排门锁解锁",
domains=("door",),
commands=("door rear unlock",),
)
window_targets = {
"ALL": ("全部车窗", "all"),
"FRONT": ("前排车窗", "front"),
"REAR": ("后排车窗", "rear"),
"FRONT-LEFT": ("左前车窗", "front-left"),
"FRONT-RIGHT": ("右前车窗", "front-right"),
"REAR-LEFT": ("左后车窗", "rear-left"),
"REAR-RIGHT": ("右后车窗", "rear-right"),
}
window_motions = {
"OPEN": ("打开", "open"),
"CLOSE": ("关闭", "close"),
"STOP": ("停止", "stop"),
}
for target_id, (target_text, target_command) in window_targets.items():
for motion_id, (verb, motion) in window_motions.items():
add(
f"CAN1-WINDOW-{target_id}-{motion_id}",
f"{verb}{target_text}",
domains=("window",),
commands=(f"window {target_command} {motion}",),
)
for target_id, (target_text, target_command) in tuple(window_targets.items())[3:]:
for position in (0, 25, 50, 100):
add(
f"CAN1-WINDOW-{target_id}-POSITION-{position}",
f"把{target_text}开到{position}%",
domains=("window",),
commands=(f"window {target_command} position {position}",),
)
for alias_id, alias, target in (
("DRIVER", "主驾", "front-left"),
("PASSENGER", "副驾", "front-right"),
):
for motion_id, (verb, motion) in window_motions.items():
add(
f"CAN1-WINDOW-{alias_id}-ALIAS-{motion_id}",
f"{verb}{alias}车窗",
domains=("window",),
commands=(f"window {target} {motion}",),
)
add("CAN1-AC-ON", "打开空调", domains=("climate",), commands=("ac on",))
add("CAN1-AC-OFF", "关闭空调", domains=("climate",), commands=("ac off",))
for level in range(1, 8):
add(
f"CAN1-AC-FAN-{level}",
f"空调风量调到{level}档",
domains=("climate",),
commands=(f"ac fan {level}",),
)
for alias_id, alias, level in (
("MIN", "最小", 1),
("LOWEST", "最低", 1),
("LOW", "低档", 1),
("MIDDLE", "中等", 4),
("MEDIUM", "中档", 4),
("HIGH", "高档", 7),
("HIGHEST", "最高", 7),
("MAX", "最大", 7),
):
add(
f"CAN1-AC-FAN-ALIAS-{alias_id}",
f"空调风量设置为{alias}",
domains=("climate",),
commands=(f"ac fan {level}",),
)
outlet_modes = (
("FACE", "吹脸", "face"),
("FACE-FEET", "吹面和吹脚", "face-feet"),
("FEET", "吹脚", "feet"),
("FEET-DEFROST", "吹脚除霜", "feet-defrost"),
("DEFROST", "除霜", "defrost"),
("FACE-FEET-DEFROST", "吹脸、吹脚和除霜", "face-feet-defrost"),
("FACE-DEFROST", "吹脸和除霜", "face-defrost"),
)
for mode_id, mode_text, mode in outlet_modes:
add(
f"CAN1-AC-MODE-{mode_id}",
f"空调出风模式设置为{mode_text}",
domains=("climate",),
commands=(f"ac mode {mode}",),
)
for target_id, target_text, target in (
("DRIVER", "主驾", "driver"),
("PASSENGER", "副驾", "passenger"),
("REAR", "后排", "rear"),
):
for temperature in (18, 24, 32):
add(
f"CAN1-AC-TEMP-{target_id}-{temperature}",
f"{target_text}空调温度设置为{temperature}度",
domains=("climate",),
commands=(f"ac temp {target} {temperature}",),
)
for mode_id, mode_text, mode in (
("MANUAL", "手动控制", "manual"),
("AUTO", "自动控制", "auto"),
):
add(
f"CAN1-AC-CONTROL-{mode_id}",
f"空调设置为{mode_text}",
domains=("climate",),
commands=(f"ac control {mode}",),
)
for cycle_id, cycle_text, cycle in (
("INTERNAL", "内循环", "internal"),
("EXTERNAL", "外循环", "external"),
):
add(
f"CAN1-AC-CYCLE-{cycle_id}",
f"空调设置为{cycle_text}",
domains=("climate",),
commands=(f"ac cycle {cycle}",),
)
outlet_targets = (
("DRIVER-LEFT", "主驾左", "driver-left", (-25, -40)),
("DRIVER-RIGHT", "主驾右", "driver-right", (30, 30)),
("PASSENGER-LEFT", "副驾左", "passenger-left", (-10, -40)),
("PASSENGER-RIGHT", "副驾右", "passenger-right", (5, 30)),
)
for target_id, target_text, target, calibrated in outlet_targets:
for horizontal, vertical in ((0, 0), (25, 75), (100, 100)):
add(
f"CAN1-OUTLET-{target_id}-PERCENT-{horizontal}-{vertical}",
f"{target_text}风口水平{horizontal}%上下{vertical}%",
domains=("outlet",),
commands=(
f"outlet {target} percent {horizontal} {vertical}",
),
)
add(
f"CAN1-OUTLET-{target_id}-CALIBRATED",
f"{target_text}风口标定坐标左右{calibrated[0]}上下{calibrated[1]}",
domains=("outlet",),
commands=(
f"outlet {target} position {calibrated[0]} {calibrated[1]}",
),
)
for feature_id, feature_text, feature in (
("VENT", "通风", "vent"),
("HEAT", "加热", "heat"),
):
for level_id, level_text, level in (
("OFF", "关闭", "off"),
("LOW", "低档", "low"),
("MIN", "最低", "low"),
("HIGH", "高档", "high"),
("MAX", "最大", "high"),
):
utterance = (
f"关闭主驾座椅{feature_text}"
if level == "off"
else f"主驾座椅{feature_text}设置为{level_text}"
)
add(
f"CAN1-SEAT-DRIVER-{feature_id}-{level_id}",
utterance,
domains=("seat",),
commands=(f"seat driver {feature} {level}",),
)
add(
f"CAN1-SEAT-DRIVER-{feature_id}-OPEN-DEFAULT",
f"打开主驾座椅{feature_text}",
domains=("seat",),
commands=(f"seat driver {feature} low",),
)
add(
"CAN1-SEAT-DRIVER-MASSAGE-OPEN",
"打开主驾座椅按摩",
domains=("seat",),
commands=("seat driver massage mode relax",),
)
add(
"CAN1-SEAT-DRIVER-MASSAGE-OFF",
"关闭主驾座椅按摩",
domains=("seat",),
commands=("seat driver massage off",),
)
for strength_id, strength_text, strength in (
("WEAK", "最小", "weak"),
("MEDIUM", "中等", "medium"),
("STRONG", "最大", "strong"),
("LOWEST", "最低", "weak"),
("HIGHEST", "最高", "strong"),
):
add(
f"CAN1-SEAT-DRIVER-MASSAGE-STRENGTH-{strength_id}",
f"主驾座椅按摩强度设置为{strength_text}",
domains=("seat",),
commands=(f"seat driver massage strength {strength}",),
)
for mode_id, mode_text, mode in (
("WAVE", "波浪", "wave"),
("PULSE", "脉冲", "pulse"),
("STRETCH", "伸展", "stretch"),
("SLACK", "松弛", "slack"),
("RELAX", "放松", "relax"),
):
add(
f"CAN1-SEAT-DRIVER-MASSAGE-MODE-{mode_id}",
f"主驾座椅按摩模式设置为{mode_text}",
domains=("seat",),
commands=(f"seat driver massage mode {mode}",),
)
driver_motion_cases = (
("HORIZONTAL-FORWARD", "主驾座椅向前移动500毫秒", "horizontal forward 500"),
("HORIZONTAL-BACKWARD", "主驾座椅后退两秒", "horizontal backward 2000"),
("HORIZONTAL-STOP", "停止主驾座椅前后移动", "horizontal stop"),
("HEIGHT-UP", "主驾座椅往上移动300毫秒", "height up 300"),
("HEIGHT-DOWN", "主驾座椅向下移动一秒", "height down 1000"),
("HEIGHT-STOP", "停止主驾座椅高度调节", "height stop"),
("BACKREST-FORWARD", "主驾座椅靠背调直500毫秒", "backrest forward 500"),
("BACKREST-BACKWARD", "主驾座椅椅背放倒800毫秒", "backrest backward 800"),
("BACKREST-STOP", "停止主驾座椅靠背动作", "backrest stop"),
("BASIN-UP", "主驾座椅坐盆抬高300毫秒", "basin up 300"),
("BASIN-DOWN", "主驾座椅座垫放低一秒", "basin down 1000"),
("BASIN-STOP", "停止主驾座椅坐盆动作", "basin stop"),
)
for case_id, utterance, suffix in driver_motion_cases:
add(
f"CAN1-SEAT-DRIVER-{case_id}",
utterance,
domains=("seat",),
commands=(f"seat driver {suffix}",),
)
add(
"CAN1-SEAT-DRIVER-STOP-ALL",
"停止主驾座椅移动",
domains=("seat",),
commands=(
"seat driver horizontal stop",
"seat driver height stop",
"seat driver backrest stop",
"seat driver basin stop",
),
)
for slot in (1, 2, 3):
add(
f"CAN1-SEAT-DRIVER-MEMORY-{slot}",
f"调用主驾座椅记忆位置{slot}",
domains=("seat",),
commands=(f"seat driver position {slot}",),
)
for state_id, state_text, state in (
("ON", "打开", "on"),
("OFF", "关闭", "off"),
):
add(
f"CAN1-LIGHT-INDOOR-{state_id}",
f"{state_text}室内灯",
domains=("interior_light",),
commands=(f"light indoor {state}",),
)
add(
f"CAN1-LIGHT-READING-ALL-{state_id}",
f"{state_text}全部阅读灯",
domains=("interior_light",),
commands=(f"light reading all {state}",),
)
add(
f"CAN1-LIGHT-AMBIENT-{state_id}",
f"{state_text}氛围灯",
domains=("ambient_light",),
commands=(f"light ambient {state}",),
)
for color in (1, 32, 70, 127):
add(
f"CAN1-LIGHT-AMBIENT-COLOR-{color}",
f"氛围灯颜色编号设置为{color}",
domains=("ambient_light",),
commands=(f"light ambient color {color}",),
)
for brightness in range(1, 6):
add(
f"CAN1-LIGHT-AMBIENT-BRIGHTNESS-{brightness}",
f"氛围灯亮度设置为{brightness}档",
domains=("ambient_light",),
commands=(f"light ambient brightness {brightness}",),
)
for alias_id, alias, brightness in (
("MIN", "最低", 1),
("MIDDLE", "中等", 3),
("MAX", "最高", 5),
):
add(
f"CAN1-LIGHT-AMBIENT-BRIGHTNESS-ALIAS-{alias_id}",
f"氛围灯亮度设置为{alias}",
domains=("ambient_light",),
commands=(f"light ambient brightness {brightness}",),
)
for motion_id, motion_text, motion in (
("OPEN", "打开", "open"),
("CLOSE", "关闭", "close"),
("HALF", "半开", "half"),
("STOP", "停止", "stop"),
):
utterance = f"遮阳帘{motion_text}" if motion == "half" else f"{motion_text}遮阳帘"
add(
f"CAN1-SHADE-{motion_id}",
utterance,
domains=("roof",),
commands=(f"shade {motion}",),
)
for position in (0, 20, 30, 50, 100):
add(
f"CAN1-SHADE-POSITION-{position}",
f"遮阳帘打开到{position}%",
domains=("roof",),
commands=(f"shade position {position}",),
)
can0_atoms = (
(
"CAN0-SEAT-PASSENGER-HEAT-1",
"副驾驶座椅加热开到1档",
can0_operation("seat.set_heating", {"state": "on", "level": 1}),
),
(
"CAN0-SEAT-PASSENGER-HEAT-2",
"副驾驶座椅加热开到2档",
can0_operation("seat.set_heating", {"state": "on", "level": 2}),
),
(
"CAN0-SEAT-PASSENGER-HEAT-3",
"副驾驶座椅加热开到3档",
can0_operation("seat.set_heating", {"state": "on", "level": 3}),
),
(
"CAN0-SEAT-PASSENGER-HEAT-OFF",
"关闭副驾驶座椅加热",
can0_operation("seat.set_heating", {"state": "off"}),
),
(
"CAN0-SEAT-PASSENGER-VENT-1",
"副驾驶座椅通风开到1档",
can0_operation("seat.set_ventilation", {"state": "on", "level": 1}),
),
(
"CAN0-SEAT-PASSENGER-VENT-2",
"副驾驶座椅通风开到2档",
can0_operation("seat.set_ventilation", {"state": "on", "level": 2}),
),
(
"CAN0-SEAT-PASSENGER-VENT-3",
"副驾驶座椅通风开到3档",
can0_operation("seat.set_ventilation", {"state": "on", "level": 3}),
),
(
"CAN0-SEAT-PASSENGER-VENT-OFF",
"关闭副驾驶座椅通风",
can0_operation("seat.set_ventilation", {"state": "off"}),
),
(
"CAN0-SEAT-PASSENGER-HORIZONTAL-30",
"把副驾驶座椅水平位置调到30%",
can0_operation("seat.set_longitudinal_position", {"position_pct": 30}),
),
(
"CAN0-SEAT-PASSENGER-BASIN-40",
"把副驾驶坐盆倾角调到40%",
can0_operation("seat.set_cushion_tilt", {"position_pct": 40}),
),
(
"CAN0-SEAT-PASSENGER-BACKREST-120",
"把副驾驶靠背调到120度",
can0_operation(
"seat.set_backrest_recline",
{"position_pct": 37, "angle_deg": 120},
),
),
(
"CAN0-SEAT-PASSENGER-LEGREST-125",
"把副驾驶腿托抬到125度",
can0_operation(
"seat.set_legrest_height",
{"position_pct": 42, "angle_deg": 125},
),
),
)
for case_id, utterance, operation in can0_atoms:
add(
case_id,
utterance,
domains=("seat",),
channels=("can0",),
can0_operations=(operation,),
)
atom_by_id = {case["id"]: case for case in cases}
def combine(
case_id: str,
source_ids: tuple[str, ...],
*,
separator: str = ",",
kind: str,
) -> None:
source_cases = [atom_by_id[source_id] for source_id in source_ids]
channels = tuple(
channel
for channel in ("can0", "can1")
if any(channel in item["channels"] for item in source_cases)
)
domains = tuple(
dict.fromkeys(
domain for item in source_cases for domain in item["domains"]
)
)
add(
case_id,
separator.join(item["utterance"] for item in source_cases),
domains=domains,
channels=channels,
commands=tuple(
command
for item in source_cases
for command in item["expected_commands"]
),
can0_operations=tuple(
operation
for item in source_cases
for operation in item["expected_can0_operations"]
),
kind=kind,
sources=source_ids,
)
pair_pool = (
"CAN1-DOOR-REAR-LOCK",
"CAN1-WINDOW-FRONT-LEFT-OPEN",
"CAN1-WINDOW-REAR-RIGHT-POSITION-25",
"CAN1-AC-ON",
"CAN1-AC-FAN-2",
"CAN1-AC-MODE-FACE-FEET",
"CAN1-AC-TEMP-DRIVER-24",
"CAN1-AC-TEMP-PASSENGER-24",
"CAN1-AC-TEMP-REAR-24",
"CAN1-AC-CONTROL-AUTO",
"CAN1-AC-CYCLE-INTERNAL",
"CAN1-OUTLET-DRIVER-LEFT-PERCENT-25-75",
"CAN1-OUTLET-PASSENGER-RIGHT-PERCENT-25-75",
"CAN1-SEAT-DRIVER-VENT-HIGH",
"CAN1-SEAT-DRIVER-HEAT-LOW",
"CAN1-SEAT-DRIVER-MASSAGE-MODE-RELAX",
"CAN1-SEAT-DRIVER-MASSAGE-STRENGTH-MEDIUM",
"CAN1-SEAT-DRIVER-HORIZONTAL-FORWARD",
"CAN1-SEAT-DRIVER-HEIGHT-UP",
"CAN1-LIGHT-INDOOR-ON",
"CAN1-LIGHT-READING-ALL-ON",
"CAN1-LIGHT-AMBIENT-ON",
"CAN1-LIGHT-AMBIENT-COLOR-70",
"CAN1-LIGHT-AMBIENT-BRIGHTNESS-3",
"CAN1-SHADE-POSITION-30",
)
for index, source_pair in enumerate(combinations(pair_pool, 2), start=1):
combine(
f"COMBO-PAIR-COMMA-{index:03d}",
source_pair,
kind="pairwise",
)
combine(
f"COMBO-PAIR-SEMICOLON-{index:03d}",
source_pair,
separator=";",
kind="pairwise_separator_variant",
)
multi_anchors = (
"CAN1-DOOR-REAR-LOCK",
"CAN1-WINDOW-FRONT-LEFT-OPEN",
"CAN1-AC-FAN-2",
"CAN1-OUTLET-DRIVER-LEFT-PERCENT-25-75",
"CAN1-SEAT-DRIVER-VENT-HIGH",
"CAN1-LIGHT-INDOOR-ON",
"CAN1-LIGHT-AMBIENT-COLOR-70",
"CAN1-SHADE-POSITION-30",
"CAN0-SEAT-PASSENGER-HEAT-2",
)
for index, source_ids in enumerate(combinations(multi_anchors, 3), start=1):
combine(
f"COMBO-TRIPLE-{index:03d}",
source_ids,
kind="three_command",
)
for index, source_ids in enumerate(combinations(multi_anchors, 4), start=1):
combine(
f"COMBO-QUADRUPLE-{index:03d}",
source_ids,
kind="four_command",
)
cross_can1 = pair_pool[:15]
cross_can0 = tuple(item[0] for item in can0_atoms[0:8])
for index, (can1_id, can0_id) in enumerate(
((left, right) for left in cross_can1 for right in cross_can0),
start=1,
):
combine(
f"COMBO-CROSS-CHANNEL-{index:03d}",
(can1_id, can0_id),
kind="cross_can0_can1",
)
scenes = (
(
"MOVIE",
(
"CAN1-AC-ON",
"CAN1-AC-TEMP-DRIVER-24",
"CAN1-AC-FAN-2",
"CAN1-SHADE-POSITION-100",
"CAN1-LIGHT-AMBIENT-COLOR-70",
"CAN1-LIGHT-AMBIENT-BRIGHTNESS-3",
),
),
(
"DRIVER-COMFORT",
(
"CAN1-SEAT-DRIVER-VENT-LOW",
"CAN1-SEAT-DRIVER-MASSAGE-MODE-RELAX",
"CAN1-SEAT-DRIVER-MASSAGE-STRENGTH-MEDIUM",
"CAN1-AC-TEMP-DRIVER-24",
"CAN1-AC-FAN-2",
),
),
(
"PASSENGER-COMFORT",
(
"CAN0-SEAT-PASSENGER-HEAT-2",
"CAN0-SEAT-PASSENGER-HORIZONTAL-30",
"CAN0-SEAT-PASSENGER-BASIN-40",
"CAN0-SEAT-PASSENGER-BACKREST-120",
"CAN0-SEAT-PASSENGER-LEGREST-125",
),
),
(
"CABIN-BRIGHT",
(
"CAN1-LIGHT-INDOOR-ON",
"CAN1-LIGHT-READING-ALL-ON",
"CAN1-LIGHT-AMBIENT-ON",
"CAN1-LIGHT-AMBIENT-COLOR-70",
"CAN1-LIGHT-AMBIENT-BRIGHTNESS-5",
),
),
(
"VENTILATION",
(
"CAN1-AC-ON",
"CAN1-AC-FAN-7",
"CAN1-AC-MODE-FACE-FEET",
"CAN1-AC-CYCLE-EXTERNAL",
"CAN1-OUTLET-DRIVER-LEFT-PERCENT-25-75",
"CAN1-OUTLET-PASSENGER-RIGHT-PERCENT-25-75",
),
),
(
"PARK-CLOSE",
(
"CAN1-WINDOW-ALL-CLOSE",
"CAN1-SHADE-CLOSE",
"CAN1-DOOR-REAR-LOCK",
"CAN1-LIGHT-INDOOR-OFF",
"CAN1-LIGHT-AMBIENT-OFF",
"CAN1-AC-OFF",
),
),
(
"DUAL-SEAT",
(
"CAN1-SEAT-DRIVER-VENT-HIGH",
"CAN1-SEAT-DRIVER-MASSAGE-MODE-WAVE",
"CAN0-SEAT-PASSENGER-VENT-2",
"CAN0-SEAT-PASSENGER-BACKREST-120",
"CAN1-AC-TEMP-DRIVER-24",
"CAN1-AC-TEMP-PASSENGER-24",
),
),
(
"ALL-ZONES-CLIMATE",
(
"CAN1-AC-ON",
"CAN1-AC-TEMP-DRIVER-24",
"CAN1-AC-TEMP-PASSENGER-24",
"CAN1-AC-TEMP-REAR-24",
"CAN1-AC-FAN-4",
"CAN1-AC-CONTROL-AUTO",
"CAN1-AC-CYCLE-INTERNAL",
),
),
(
"WINDOWS-SHADE",
(
"CAN1-WINDOW-FRONT-LEFT-POSITION-25",
"CAN1-WINDOW-FRONT-RIGHT-POSITION-50",
"CAN1-WINDOW-REAR-LEFT-POSITION-100",
"CAN1-WINDOW-REAR-RIGHT-POSITION-0",
"CAN1-SHADE-POSITION-30",
),
),
(
"DRIVER-POSTURE",
(
"CAN1-SEAT-DRIVER-HORIZONTAL-FORWARD",
"CAN1-SEAT-DRIVER-HEIGHT-UP",
"CAN1-SEAT-DRIVER-BACKREST-BACKWARD",
"CAN1-SEAT-DRIVER-BASIN-DOWN",
),
),
)
for scene_id, source_ids in scenes:
combine(
f"COMBO-SCENE-{scene_id}",
source_ids,
kind="multi_command_scene",
)
safety_cases = (
("DRIVER-VENT-LEVEL-4", "主驾座椅通风4档"),
("DRIVER-VENT-MIDDLE", "主驾座椅通风中档"),
("DRIVER-ABSOLUTE-BACKREST", "把驾驶位靠背调到50%"),
("DRIVER-UNVERIFIED-MODE", "主驾进入零重力模式"),
("FRONT-ROW-SEAT", "打开前排座椅加热"),
("PASSENGER-HEAT-LEVEL-4", "副驾座椅加热开到4档"),
("PASSENGER-VENT-MISSING-STATE", "副驾座椅通风"),
("PASSENGER-CONTRADICTORY", "打开副驾加热然后关闭副驾加热"),
("PASSENGER-MASSAGE", "把副驾座椅按摩打开"),
("MULTIPLE-SEAT-MODES", "进入办公模式和零重力模式"),
("PASSENGER-LEGREST-EXTEND", "把副驾腿托伸长到50%"),
("WRONG-AXIS-UNIT", "坐垫向前移动10度"),
("MISSING-SEAT-AXIS", "向前移动10度"),
("REAR-SEAT", "打开后排座椅加热"),
("ALL-SEATS", "打开所有座椅加热"),
("OUTLET-DISABLE-WITHOUT-MODE", "关闭吹面出风口"),
)
for case_id, utterance in safety_cases:
add(
f"SAFETY-{case_id}",
utterance,
domains=("safety",),
channels=(),
kind="safety_rejection",
status="needs_confirmation",
)
return cases
def write_outputs(cases: list[dict[str, Any]]) -> None:
kind_counts = Counter(case["kind"] for case in cases)
channel_counts = Counter(
"+".join(case["channels"]) or "none" for case in cases
)
payload = {
"schema": "natural-can-command-combination-suite/v1",
"generated_on": "2026-08-24",
"description": "自然语言控制指令到CAN0/CAN1语义控制指令的原子、组合和安全测试目录。",
"summary": {
"total_cases": len(cases),
"by_kind": dict(sorted(kind_counts.items())),
"by_channel": dict(sorted(channel_counts.items())),
},
"cases": cases,
}
JSON_PATH.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
with CSV_PATH.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(
handle,
fieldnames=(
"id",
"kind",
"channels",
"domains",
"utterance",
"expected_status",
"expected_commands",
"expected_can0_operations",
"source_case_ids",
),
)
writer.writeheader()
for case in cases:
writer.writerow(
{
**case,
"channels": "/".join(case["channels"]),
"domains": "/".join(case["domains"]),
"expected_commands": " | ".join(case["expected_commands"]),
"expected_can0_operations": json.dumps(
case["expected_can0_operations"], ensure_ascii=False
),
"source_case_ids": " | ".join(
case.get("source_case_ids", [])
),
}
)
kind_lines = "\n".join(
f"| `{kind}` | {count} |" for kind, count in sorted(kind_counts.items())
)
channel_lines = "\n".join(
f"| `{channel}` | {count} |"
for channel, count in sorted(channel_counts.items())
)
DOC_PATH.write_text(
f"""# 自然语言转 CAN 控制指令组合测试说明
- 生成日期:2026-08-24
- 总用例数:**{len(cases)}**
- 执行方式:本地规则、`dry_run`,不发送真实 CAN 帧
- 完整逐条清单:`docs/testing/自然语言转CAN组合测试用例.csv`
- 机器可执行数据:`tests/fixtures/natural_can_command_combination_suite.json`
## 覆盖数量
| 用例类型 | 数量 |
| --- | ---: |
{kind_lines}
| 通道 | 数量 |
| --- | ---: |
{channel_lines}
## 组合策略
1. 原子用例覆盖 CAN1 门锁、车窗、空调、风口、主驾座椅、灯光、遮阳帘,以及 CAN0 副驾座椅。
2. 两两组合从 25 个互不冲突的控制槽中取组合,同时覆盖中文逗号和分号分隔形式。
3. 三指令和四指令组合覆盖 CAN0/CAN1、座椅、空调、灯光、车窗、门锁、风口与遮阳帘的交叉。
4. 跨通道组合明确检查副驾座椅留在 CAN0、主驾和车身控制留在 CAN1。
5. 多指令场景覆盖观影、双座椅、全温区空调、泊车关闭、车窗与遮阳帘等常用场景。
6. 安全用例要求白名单外目标、档位、能力和矛盾指令不生成 CAN 控制流。
## 执行
```bash
PYTHONPATH=src:domains/seat/src .venv/bin/python -m pytest -q tests/integration/test_natural_can_command_combinations.py
```
重新生成清单:
```bash
.venv/bin/python tools/generate_natural_can_command_suite.py
```
""",
encoding="utf-8",
)
def main() -> None:
cases = build_cases()
ids = [case["id"] for case in cases]
if len(ids) != len(set(ids)):
duplicates = [item for item, count in Counter(ids).items() if count > 1]
raise ValueError(f"duplicate case ids: {duplicates}")
write_outputs(cases)
print(f"generated {len(cases)} cases")
print(JSON_PATH)
print(CSV_PATH)
print(DOC_PATH)
if __name__ == "__main__":
main()