优化了副驾座椅控制
This commit is contained in:
+62
-3
@@ -75,6 +75,7 @@ const state = {
|
||||
modeDraftCommand: "",
|
||||
lastSuccessfulUtterance: "",
|
||||
modeEditor: null,
|
||||
directCanCommandPending: false,
|
||||
};
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
@@ -7144,7 +7145,7 @@ function authoritativeCh1StatePatch(value) {
|
||||
function physicalSeatStatePatch(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const source = value.source || value.feedback_source;
|
||||
if (["physical_can", "physical_can_ch0", "physical_partial"].includes(source)) {
|
||||
if (["physical_can", "physical_can_ch0", "wireless_mqtt_ch0", "physical_partial"].includes(source)) {
|
||||
return value;
|
||||
}
|
||||
const patch = {};
|
||||
@@ -7385,6 +7386,56 @@ async function postControlPhase(path, body, executionMode, onEvent = null) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function executeCanCommands(canCommands, { deviceName = "车辆设备" } = {}) {
|
||||
const commands = Array.isArray(canCommands)
|
||||
? canCommands.map((command) => String(command || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
if (!commands.length) throw new Error("没有可发送的 CAN 白名单控制指令");
|
||||
if (state.directCanCommandPending) throw new Error("上一组 CAN 控制指令仍在执行,请稍候");
|
||||
|
||||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||||
if (state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
&& !isLocalDecisionHost()
|
||||
&& state.hardwareAccess?.state !== "approved") {
|
||||
throw new Error(state.hardwareAccess?.state === "pending"
|
||||
? "申请仍在等待本地审批"
|
||||
: "请先申请并获得本地真实台架使用权");
|
||||
}
|
||||
|
||||
state.directCanCommandPending = true;
|
||||
try {
|
||||
const payload = await postControlPhase(
|
||||
"/api/can/commands",
|
||||
{
|
||||
commands,
|
||||
client_type: "web_console_device_control",
|
||||
conversation_id: state.conversationId,
|
||||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||||
execution_mode: state.executionMode,
|
||||
control_transport: state.controlTransport,
|
||||
...(state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
...(state.executionMode === "simulated_hardware"
|
||||
? { simulation_fault: $("#simulationFault").value }
|
||||
: {}),
|
||||
},
|
||||
state.executionMode,
|
||||
);
|
||||
if (!payload) throw new Error("CAN 控制请求未返回结果");
|
||||
renderResult(payload);
|
||||
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
|
||||
showToast(`${deviceName} CAN 控制已发送`);
|
||||
return payload;
|
||||
} finally {
|
||||
state.directCanCommandPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRequest(event) {
|
||||
event.preventDefault();
|
||||
const utterance = $("#utterance").value.trim();
|
||||
@@ -7397,6 +7448,7 @@ async function submitRequest(event) {
|
||||
}
|
||||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||||
if (state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
&& !isLocalDecisionHost()
|
||||
&& state.hardwareAccess?.state !== "approved") {
|
||||
@@ -7430,7 +7482,9 @@ async function submitRequest(event) {
|
||||
execution_mode: state.executionMode,
|
||||
control_transport: state.controlTransport,
|
||||
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
|
||||
...(state.executionMode === "hardware" && !driverOnlyCh1
|
||||
...(state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
...(state.executionMode === "simulated_hardware"
|
||||
@@ -7466,7 +7520,9 @@ async function submitRequest(event) {
|
||||
client_type: "web_console",
|
||||
provider: state.provider,
|
||||
reasoning_effort: state.reasoningEffort,
|
||||
...(state.executionMode === "hardware" && !driverOnlyCh1
|
||||
...(state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
},
|
||||
@@ -7630,6 +7686,9 @@ window.CognitiveAgentConsole = {
|
||||
if (autoSubmit) $("#requestForm").requestSubmit();
|
||||
return true;
|
||||
},
|
||||
executeCanCommands(canCommands, options = {}) {
|
||||
return executeCanCommands(canCommands, options);
|
||||
},
|
||||
getExecutionMode() {
|
||||
return state.executionMode;
|
||||
},
|
||||
|
||||
@@ -7219,6 +7219,12 @@ body.device-power-drawer-open {
|
||||
border-color: rgba(8, 118, 111, 0.14);
|
||||
}
|
||||
|
||||
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-source[data-parse-source="direct_control"] {
|
||||
color: #7a4b00;
|
||||
background: #fff0c7;
|
||||
border-color: rgba(122, 75, 0, 0.16);
|
||||
}
|
||||
|
||||
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="CAN0"] {
|
||||
background: #f7f4ec;
|
||||
}
|
||||
|
||||
+164
-56
@@ -80,8 +80,8 @@
|
||||
const isPassengerSeat = new Set(["passenger", "front-right"]).has(target);
|
||||
if (isPassengerSeat) {
|
||||
const passengerAxes = {
|
||||
"seat.set_longitudinal_position": ["副驾座椅前后调节", "longitudinal", 0],
|
||||
"seat.set_cushion_tilt": ["副驾座椅高度调节", "height", 1],
|
||||
"seat.set_longitudinal_position": ["副驾座椅前后调节", "horizontal", 0],
|
||||
"seat.set_cushion_tilt": ["副驾座椅高度调节", "basin", 1],
|
||||
"seat.set_backrest_recline": ["副驾座椅靠背调节", "backrest", 2],
|
||||
"seat.set_legrest_height": ["副驾座椅腿托调节", "legrest", 3],
|
||||
};
|
||||
@@ -89,13 +89,38 @@
|
||||
const position = boundedInteger(parameters.position_pct, 0, 100);
|
||||
if (axis && position !== null) {
|
||||
const targetPermille = position * 10;
|
||||
const motion = operation.motion || {};
|
||||
const delta = boundedInteger(motion.delta_position_pct, 1, 100);
|
||||
const relativeDirections = {
|
||||
horizontal: { increase: "backward", decrease: "forward" },
|
||||
basin: { increase: "down", decrease: "up" },
|
||||
backrest: { increase: "backward", decrease: "forward" },
|
||||
legrest: { increase: "up", decrease: "down" },
|
||||
};
|
||||
const relativeDirection = relativeDirections[axis[1]]?.[motion.direction];
|
||||
return command(
|
||||
axis[0],
|
||||
`seat passenger ${axis[1]} position ${position}`,
|
||||
motion.mode === "relative" && relativeDirection && delta !== null
|
||||
? `seat passenger ${axis[1]} relative ${relativeDirection} ${delta}`
|
||||
: `seat passenger ${axis[1]} position ${position}`,
|
||||
"CAN0",
|
||||
otaCan0Frame(0x64, axis[2], targetPermille),
|
||||
);
|
||||
}
|
||||
if (new Set(["seat.set_ventilation", "seat.set_heating"]).has(capability)) {
|
||||
const feature = capability === "seat.set_heating" ? "heat" : "vent";
|
||||
const level = boundedInteger(parameters.level, 1, 3);
|
||||
const setting = onOff === "off"
|
||||
? "off"
|
||||
: ({ 1: "low", 2: "medium", 3: "high" })[level];
|
||||
if (setting) {
|
||||
return command(
|
||||
"副驾座椅通风/加热",
|
||||
`seat passenger ${feature} ${setting}`,
|
||||
"CAN0",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (capability === "seat.start_dynamic_mode") {
|
||||
const mode = boundedInteger(parameters.mode_id, 1, 2);
|
||||
if (mode !== null) {
|
||||
@@ -104,7 +129,7 @@
|
||||
: { name: "cradle", selector: 1 };
|
||||
return command(
|
||||
"副驾座椅动态模式",
|
||||
`seat passenger dynamic start ${dynamic.name}`,
|
||||
`seat passenger dynamic ${dynamic.name}`,
|
||||
"CAN0",
|
||||
otaCan0Frame(0x66, dynamic.selector, 300),
|
||||
);
|
||||
@@ -301,7 +326,7 @@
|
||||
<li data-can-channel="${escapeHtml(item.channel)}">
|
||||
<span>
|
||||
<b class="v2-can-command-channel">${escapeHtml(item.channel)}</b>
|
||||
${item.parseSource ? `<b class="v2-can-command-source" data-parse-source="${escapeHtml(item.parseSource)}">${item.parseSource === "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)}
|
||||
</span>
|
||||
<code>${escapeHtml(item.command)}</code>
|
||||
@@ -313,6 +338,30 @@
|
||||
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() {
|
||||
reasoningPickerControl?.close();
|
||||
}
|
||||
@@ -620,7 +669,7 @@
|
||||
function seatLevelControlValue(seat, feature) {
|
||||
const text = seatFunctionValue(seat, feature);
|
||||
const level = text.match(/L(\d+)/i)?.[1];
|
||||
return /OFF|关闭|--/.test(text) ? "off" : level || "1";
|
||||
return /OFF|关闭|--/.test(text) ? "off" : level === "2" ? "low" : "high";
|
||||
}
|
||||
|
||||
function massageReadout() {
|
||||
@@ -632,34 +681,31 @@
|
||||
}
|
||||
|
||||
const levelOptions = [
|
||||
["off", "关闭"], ["1", "1 档"], ["2", "2 档"], ["3", "3 档"],
|
||||
["off", "关闭"], ["high", "1 档(高)"], ["low", "2 档(低)"],
|
||||
];
|
||||
const toggleOptions = [["off", "关闭"], ["on", "开启"]];
|
||||
const massageModeOptions = [
|
||||
["off", "关闭"], ["relax", "舒缓"], ["stretch", "伸展"], ["waist", "腰部"], ["wave", "波浪"],
|
||||
["off", "关闭"], ["relax", "放松"], ["stretch", "伸展"], ["slack", "松弛"], ["wave", "波浪"], ["pulse", "脉冲"],
|
||||
];
|
||||
const massageStrengthOptions = [
|
||||
["off", "关闭"], ["low", "低"], ["medium", "中"], ["high", "高"],
|
||||
["off", "关闭"], ["weak", "低"], ["medium", "中"], ["strong", "高"],
|
||||
];
|
||||
const colorIndexNames = ["皓月白", "冰川蓝", "松石绿", "星云紫", "琥珀橙", "玫瑰红"];
|
||||
const colorIndexOptions = Array.from({ length: 16 }, (_item, index) => [
|
||||
String(index), colorIndexNames[index] ? `${index} · ${colorIndexNames[index]}` : `色号 ${index}`,
|
||||
const colorIndexOptions = Array.from({ length: 127 }, (_item, index) => [
|
||||
String(index + 1), colorIndexNames[index] ? `${index + 1} · ${colorIndexNames[index]}` : `色号 ${index + 1}`,
|
||||
]);
|
||||
|
||||
const rangeControl = (key, label, value, min = 0, max = 100, step = 1, unit = "%", hint = "") => ({
|
||||
key, label, type: "range", value: clampControlNumber(value, min, max), min, max, step, unit, hint,
|
||||
});
|
||||
const selectControl = (key, label, value, options) => ({ key, label, type: "select", value, options });
|
||||
const hasControlValue = (values, key) => Object.hasOwn(values, key);
|
||||
|
||||
function deviceControlValue(deviceId, control) {
|
||||
const draft = displaySync.deviceDrafts[deviceId];
|
||||
return draft && Object.hasOwn(draft, control.key) ? draft[control.key] : control.value;
|
||||
}
|
||||
|
||||
function optionLabel(options, value) {
|
||||
return options.find(([optionValue]) => String(optionValue) === String(value))?.[1] || String(value);
|
||||
}
|
||||
|
||||
const syncedDevices = [
|
||||
{
|
||||
id: "passenger", name: "副驾座椅", icon: "armchair",
|
||||
@@ -670,7 +716,13 @@
|
||||
rangeControl("backrest_recline", "靠背", seatAxisValue("passenger", "backrest_recline")),
|
||||
rangeControl("legrest_height", "腿托", seatAxisValue("passenger", "legrest_height")),
|
||||
],
|
||||
command: (values) => `调节副驾座椅:前后${values.longitudinal}%,座椅高度${values.cushion_tilt}%,靠背${values.backrest_recline}%,腿托${values.legrest_height}%`,
|
||||
commands: (values) => [
|
||||
["longitudinal", "horizontal"],
|
||||
["cushion_tilt", "basin"],
|
||||
["backrest_recline", "backrest"],
|
||||
["legrest_height", "legrest"],
|
||||
].filter(([key]) => hasControlValue(values, key))
|
||||
.map(([key, axis]) => `seat passenger ${axis} position ${Math.round(Number(values[key]))}`),
|
||||
},
|
||||
{
|
||||
id: "driver", name: "主驾座椅", icon: "armchair",
|
||||
@@ -687,7 +739,22 @@
|
||||
selectControl("massage_strength", "按摩强度", /关闭|--/.test(massageStrength) ? "off" : massageStrength.toLowerCase(), massageStrengthOptions),
|
||||
];
|
||||
},
|
||||
command: (values) => `调节主驾座椅:通风${optionLabel(levelOptions, values.ventilation)},加热${optionLabel(levelOptions, values.heating)},按摩模式${optionLabel(massageModeOptions, values.massage_mode)},按摩强度${optionLabel(massageStrengthOptions, values.massage_strength)}`,
|
||||
commands: (values) => {
|
||||
const commands = [];
|
||||
if (hasControlValue(values, "ventilation")) commands.push(`seat driver vent ${values.ventilation}`);
|
||||
if (hasControlValue(values, "heating")) commands.push(`seat driver heat ${values.heating}`);
|
||||
if (hasControlValue(values, "massage_mode")) {
|
||||
commands.push(values.massage_mode === "off"
|
||||
? "seat driver massage off"
|
||||
: `seat driver massage mode ${values.massage_mode}`);
|
||||
}
|
||||
if (hasControlValue(values, "massage_strength")) {
|
||||
commands.push(values.massage_strength === "off"
|
||||
? "seat driver massage off"
|
||||
: `seat driver massage strength ${values.massage_strength}`);
|
||||
}
|
||||
return [...new Set(commands)];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "windows", name: "车窗", icon: "gallery-vertical-end",
|
||||
@@ -698,19 +765,24 @@
|
||||
rangeControl("rear_left", "左后窗", readText("#rearLeftWindow")),
|
||||
rangeControl("rear_right", "右后窗", readText("#rearRightWindow")),
|
||||
],
|
||||
command: (values) => `调节车窗开合比例:左前窗${values.front_left}%,右前窗${values.front_right}%,左后窗${values.rear_left}%,右后窗${values.rear_right}%`,
|
||||
commands: (values) => Object.entries({
|
||||
front_left: "front-left", front_right: "front-right", rear_left: "rear-left", rear_right: "rear-right",
|
||||
}).filter(([key]) => hasControlValue(values, key))
|
||||
.map(([key, target]) => `window ${target} position ${Math.round(Number(values[key]))}`),
|
||||
},
|
||||
{
|
||||
id: "shade", name: "遮阳帘", icon: "panels-top-left",
|
||||
rows: () => [["当前位置", readText("#shadePositionValue")]],
|
||||
controls: () => [rangeControl("position", "当前位置", readText("#shadePositionValue"), 0, 100, 1, "%", "0% 关闭 · 100% 打开")],
|
||||
command: (values) => `将遮阳帘开合位置调整为${values.position}%`,
|
||||
commands: (values) => hasControlValue(values, "position")
|
||||
? [`shade position ${Math.round(Number(values.position))}`]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
id: "reading", name: "阅读灯", icon: "book-open",
|
||||
rows: () => [["开关状态", readText("#interiorLightPower", "等待反馈")]],
|
||||
controls: () => [selectControl("power", "开关状态", /开启|ON/.test(readText("#interiorLightPower")) ? "on" : "off", toggleOptions)],
|
||||
command: (values) => `${values.power === "on" ? "打开" : "关闭"}阅读灯`,
|
||||
commands: (values) => hasControlValue(values, "power") ? [`light reading all ${values.power}`] : [],
|
||||
},
|
||||
{
|
||||
id: "ambient", name: "氛围灯", icon: "sparkles",
|
||||
@@ -724,31 +796,39 @@
|
||||
const [frontColor, rearColor] = splitReadout("#ambientColorIndex");
|
||||
return [
|
||||
selectControl("power", "开关状态", /OFF|关闭/.test(readText("#ambientPower")) ? "off" : "on", toggleOptions),
|
||||
rangeControl("front_brightness", "前排亮度", frontBrightness, 0, 5, 1, " 级"),
|
||||
selectControl("front_color", "前排颜色", String(clampControlNumber(frontColor, 0, 15, 0)), colorIndexOptions),
|
||||
rangeControl("rear_brightness", "后排亮度", rearBrightness, 0, 5, 1, " 级"),
|
||||
selectControl("rear_color", "后排颜色", String(clampControlNumber(rearColor, 0, 15, 0)), colorIndexOptions),
|
||||
rangeControl("brightness", "全车亮度", frontBrightness === "-" ? rearBrightness : frontBrightness, 1, 5, 1, " 级"),
|
||||
selectControl("color", "全车颜色", String(clampControlNumber(frontColor === "-" ? rearColor : frontColor, 1, 127, 1)), colorIndexOptions),
|
||||
];
|
||||
},
|
||||
command: (values) => `调节氛围灯:${values.power === "on" ? "开启" : "关闭"},前排亮度${values.front_brightness}级、颜色${optionLabel(colorIndexOptions, values.front_color)},后排亮度${values.rear_brightness}级、颜色${optionLabel(colorIndexOptions, values.rear_color)}`,
|
||||
commands: (values) => [
|
||||
...(hasControlValue(values, "power") ? [`light ambient ${values.power}`] : []),
|
||||
...(hasControlValue(values, "brightness") ? [`light ambient brightness ${Math.round(Number(values.brightness))}`] : []),
|
||||
...(hasControlValue(values, "color") ? [`light ambient color ${Math.round(Number(values.color))}`] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "headlight", name: "前大灯", icon: "car-front",
|
||||
controllable: false,
|
||||
rows: () => [["近光灯", readText("#lowBeamState")], ["远光灯", readText("#highBeamState")], ["雾灯", readText("#fogLightState")], ["转向灯", readText("#turnSignalState")]],
|
||||
controls: () => [["low_beam", "近光灯", "#lowBeamState"], ["high_beam", "远光灯", "#highBeamState"], ["fog_light", "雾灯", "#fogLightState"], ["turn_signal", "转向灯", "#turnSignalState"]].map(([key, label, selector]) => selectControl(key, label, /开启|ON/.test(readText(selector)) ? "on" : "off", toggleOptions)),
|
||||
command: (values) => `调节前大灯:近光灯${optionLabel(toggleOptions, values.low_beam)},远光灯${optionLabel(toggleOptions, values.high_beam)},雾灯${optionLabel(toggleOptions, values.fog_light)},转向灯${optionLabel(toggleOptions, values.turn_signal)}`,
|
||||
},
|
||||
{
|
||||
id: "climate", name: "空调", icon: "fan",
|
||||
rows: () => [["开关状态", readText("#climatePower", "等待反馈")], ["主驾温度", readText("#driverTemperature")], ["副驾温度", readText("#passengerTemperature")], ["风量", readText("#fanLevel")], ["循环模式", readText("#cycleMode")]],
|
||||
controls: () => [
|
||||
selectControl("power", "开关状态", /ON|ACTIVE|开启/.test(readText("#climatePower")) ? "on" : "off", toggleOptions),
|
||||
rangeControl("driver_temperature", "主驾温度", readText("#driverTemperature"), 16, 32, 0.5, "℃"),
|
||||
rangeControl("passenger_temperature", "副驾温度", readText("#passengerTemperature"), 16, 32, 0.5, "℃"),
|
||||
rangeControl("fan_level", "风量", readText("#fanLevel"), 0, 7, 1, " 档"),
|
||||
rangeControl("driver_temperature", "主驾温度", Math.round(clampControlNumber(readText("#driverTemperature"), 18, 32, 24)), 18, 32, 1, "℃"),
|
||||
rangeControl("passenger_temperature", "副驾温度", Math.round(clampControlNumber(readText("#passengerTemperature"), 18, 32, 24)), 18, 32, 1, "℃"),
|
||||
rangeControl("fan_level", "风量", readText("#fanLevel"), 1, 7, 1, " 档"),
|
||||
selectControl("cycle_mode", "循环模式", readText("#cycleMode").includes("内") ? "internal" : "external", [["internal", "内循环"], ["external", "外循环"]]),
|
||||
],
|
||||
command: (values) => `调节空调:${values.power === "on" ? "开启" : "关闭"},主驾${values.driver_temperature}℃,副驾${values.passenger_temperature}℃,风量${values.fan_level}档,${values.cycle_mode === "internal" ? "内循环" : "外循环"}`,
|
||||
commands: (values) => [
|
||||
...(hasControlValue(values, "power") ? [`ac ${values.power}`] : []),
|
||||
...(hasControlValue(values, "driver_temperature") ? [`ac temp driver ${Math.round(Number(values.driver_temperature))}`] : []),
|
||||
...(hasControlValue(values, "passenger_temperature") ? [`ac temp passenger ${Math.round(Number(values.passenger_temperature))}`] : []),
|
||||
...(hasControlValue(values, "fan_level") ? [`ac fan ${Math.round(Number(values.fan_level))}`] : []),
|
||||
...(hasControlValue(values, "cycle_mode") ? [`ac cycle ${values.cycle_mode}`] : []),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -771,7 +851,8 @@
|
||||
grid.innerHTML = syncedDevices.map((device) => {
|
||||
const rows = device.rows();
|
||||
const waiting = rows.some(([, value]) => /--|等待|同步中|未知/.test(value));
|
||||
const controlOpen = displaySync.controlDevice === device.id;
|
||||
const controllable = device.controllable !== false;
|
||||
const controlOpen = controllable && displaySync.controlDevice === device.id;
|
||||
const hasDraft = Boolean(displaySync.deviceDrafts[device.id]);
|
||||
const pending = Boolean(displaySync.pendingControls[device.id]);
|
||||
const stateLabel = pending ? "已发送 · 等待同步" : hasDraft ? "参数待发送" : `${rows.length} 项关键状态`;
|
||||
@@ -780,13 +861,13 @@
|
||||
<button class="v2-device-card-focus" type="button" data-device-focus="${device.id}" aria-label="在孪生界面查看${device.name}">
|
||||
<span><i data-lucide="${device.icon}"></i></span><span><strong>${device.name}</strong><small data-device-card-state>${stateLabel}</small></span><i class="v2-device-signal"></i>
|
||||
</button>
|
||||
<button class="v2-device-adjust-toggle" type="button" data-device-control-toggle="${device.id}" aria-expanded="${String(controlOpen)}"><i data-lucide="sliders-horizontal"></i><span>${controlOpen ? "收起" : "调节"}</span></button>
|
||||
<button class="v2-device-adjust-toggle" type="button" data-device-control-toggle="${device.id}" aria-expanded="${String(controlOpen)}"${controllable ? "" : " disabled"}><i data-lucide="${controllable ? "sliders-horizontal" : "eye"}"></i><span>${controllable ? (controlOpen ? "收起" : "调节") : "只读"}</span></button>
|
||||
</header>
|
||||
<dl>${rows.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}</dl>
|
||||
<div class="v2-device-controls" data-device-controls="${device.id}"${controlOpen ? "" : " hidden"}>
|
||||
<div class="v2-device-controls-intro"><span>手动调节</span><small>参数确认后通过编排链路发送至车机</small></div>
|
||||
${device.controls().map((control) => renderDeviceControl(device, control)).join("")}
|
||||
<button class="v2-device-control-apply" type="button" data-device-control-apply="${device.id}"${pending ? " disabled" : ""}><i data-lucide="send"></i><span>${pending ? "等待车机同步" : "发送控制"}</span></button>
|
||||
<button class="v2-device-control-apply" type="button" data-device-control-apply="${device.id}"${pending || !hasDraft ? " disabled" : ""}><i data-lucide="send"></i><span>${pending ? "等待车机同步" : hasDraft ? "发送 CAN 控制" : "请先修改参数"}</span></button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join("");
|
||||
@@ -817,28 +898,68 @@
|
||||
}));
|
||||
}
|
||||
|
||||
function sendDeviceControl(deviceId) {
|
||||
async function sendDeviceControl(deviceId) {
|
||||
const device = syncedDevices.find((item) => item.id === deviceId);
|
||||
if (!device) return;
|
||||
const values = Object.fromEntries(device.controls().map((control) => [control.key, String(deviceControlValue(device.id, control))]));
|
||||
const utterance = device.command(values);
|
||||
if (device.controllable === false || typeof device.commands !== "function") {
|
||||
window.CognitiveAgentConsole?.notify?.(`${device.name}当前只有状态反馈,尚无已验证的 CAN 控制白名单`);
|
||||
return;
|
||||
}
|
||||
const draft = displaySync.deviceDrafts[device.id];
|
||||
if (!draft || !Object.keys(draft).length) {
|
||||
window.CognitiveAgentConsole?.notify?.("请先修改需要发送的参数");
|
||||
return;
|
||||
}
|
||||
if (displaySync.pendingControls[device.id]) {
|
||||
window.CognitiveAgentConsole?.notify?.("该设备的上一组 CAN 指令仍在执行");
|
||||
return;
|
||||
}
|
||||
const values = Object.fromEntries(Object.entries(draft).map(([key, value]) => [key, String(value)]));
|
||||
const commands = device.commands(values).filter(Boolean);
|
||||
if (!commands.length) {
|
||||
window.CognitiveAgentConsole?.notify?.("修改项没有对应的 CAN 白名单控制指令");
|
||||
return;
|
||||
}
|
||||
const commandSummary = commands.join(";");
|
||||
const detail = {
|
||||
device_id: device.id,
|
||||
device_name: device.name,
|
||||
controls: values,
|
||||
utterance,
|
||||
can_commands: commands,
|
||||
source: "web_console",
|
||||
requested_at: new Date().toISOString(),
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-control-request", { detail }));
|
||||
const accepted = window.CognitiveAgentConsole?.executeUtterance?.(utterance, { autoSubmit: true }) || false;
|
||||
if (!accepted) {
|
||||
window.CognitiveAgentConsole?.notify?.("上一条指令仍在执行,请稍候");
|
||||
const execute = window.CognitiveAgentConsole?.executeCanCommands;
|
||||
if (typeof execute !== "function") {
|
||||
window.CognitiveAgentConsole?.notify?.("当前页面尚未加载 CAN 直控接口");
|
||||
return;
|
||||
}
|
||||
displaySync.pendingControls[device.id] = utterance;
|
||||
appendSyncLog({ category: "device", title: `网页端调节${device.name}`, detail: utterance, status: "info" });
|
||||
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-can-control-request", { detail }));
|
||||
displaySync.pendingControls[device.id] = commandSummary;
|
||||
appendSyncLog({ category: "device", title: `网页端直控${device.name}`, detail: commandSummary, status: "info" });
|
||||
renderDirectCanCommands(commands, device.name);
|
||||
renderSyncedDevices();
|
||||
try {
|
||||
await execute(commands, { deviceName: device.name });
|
||||
delete displaySync.deviceDrafts[device.id];
|
||||
appendSyncLog({
|
||||
category: "device",
|
||||
title: `${device.name} CAN 控制已执行`,
|
||||
detail: commandSummary,
|
||||
status: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
appendSyncLog({
|
||||
category: "device",
|
||||
title: `${device.name} CAN 控制发送失败`,
|
||||
detail: error?.message || "CAN 控制请求失败",
|
||||
status: "error",
|
||||
});
|
||||
window.CognitiveAgentConsole?.notify?.(error?.message || "CAN 控制请求失败");
|
||||
} finally {
|
||||
delete displaySync.pendingControls[device.id];
|
||||
renderSyncedDevices();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSyncedDeviceRender() {
|
||||
@@ -1389,19 +1510,6 @@
|
||||
});
|
||||
});
|
||||
window.addEventListener("cognitive-agent:request-complete", (event) => {
|
||||
const pendingDevice = Object.entries(displaySync.pendingControls).find(([, utterance]) => utterance === event.detail?.utterance)?.[0];
|
||||
if (pendingDevice) {
|
||||
const device = syncedDevices.find((item) => item.id === pendingDevice);
|
||||
delete displaySync.pendingControls[pendingDevice];
|
||||
if (!event.detail?.error) delete displaySync.deviceDrafts[pendingDevice];
|
||||
appendSyncLog({
|
||||
category: "device",
|
||||
title: event.detail?.error ? `${device?.name || "设备"}控制发送失败` : `${device?.name || "设备"}控制已执行`,
|
||||
detail: event.detail?.error?.message || "控制结果已进入车机状态同步链路",
|
||||
status: event.detail?.error ? "error" : "success",
|
||||
});
|
||||
renderSyncedDevices();
|
||||
}
|
||||
if (event.detail?.error && !validatedCommandsVisible) {
|
||||
renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" });
|
||||
}
|
||||
|
||||
@@ -718,7 +718,7 @@
|
||||
}
|
||||
|
||||
function isPhysicalVehicleSource(value) {
|
||||
return ["physical_can", "physical_can_ch0", "physical_can_ch1", "physical_partial"].includes(value);
|
||||
return ["physical_can", "physical_can_ch0", "physical_can_ch1", "wireless_mqtt_ch0", "wireless_mqtt_ch1", "physical_partial"].includes(value);
|
||||
}
|
||||
|
||||
function containsAuthoritativePhysicalCh1State(value) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"parameters": {"type": "object"},
|
||||
"parse_source": {
|
||||
"type": "string",
|
||||
"enum": ["local_rule", "cloud_model", "cloud_supplement"]
|
||||
"enum": ["local_rule", "cloud_model", "cloud_supplement", "direct_control"]
|
||||
},
|
||||
"motion": {
|
||||
"type": "object",
|
||||
|
||||
@@ -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
@@ -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 massage off` | 仅关闭动作 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
|
||||
| 主驾座椅按摩强度 | `seat driver massage strength <weak\|medium\|strong>` | `1\|2\|3` 也可作为输入兼容别名,规范输出为单词枚举 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
|
||||
| 主驾座椅按摩模式 | `seat driver massage mode <wave\|pulse\|stretch\|slack\|relax>` | 仅接受列出的 5 种模式 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
|
||||
| 主驾座椅按摩模式 | `seat driver massage mode <wave\|pulse\|stretch\|slack\|relax>` | 仅接受列出的 5 种模式;自然语言“打开主驾座椅按摩”使用已验证的 `relax` 模式启动 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
|
||||
| 主驾座椅水平调节 | `seat driver horizontal <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
|
||||
| 主驾座椅高度调节 | `seat driver height <up\|down> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
|
||||
| 主驾座椅靠背调节 | `seat driver backrest <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
|
||||
|
||||
@@ -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:
|
||||
values = self.matrix.decode_by_frame_id(0x4FA, frame.data)
|
||||
self._record_thermal_state(state, values, frame)
|
||||
elif frame.arbitration_id == 0x306 and len(frame.data) >= 8:
|
||||
values = self.matrix.decode_by_frame_id(0x306, frame.data)
|
||||
self._record_dynamic_state(state, values, frame)
|
||||
state["captured_at"] = _now()
|
||||
state["source"] = "physical_can"
|
||||
state["target"] = {
|
||||
@@ -461,6 +464,30 @@ class HardwareSeatExecutor:
|
||||
self._set_thermal_feature(state, feature, raw)
|
||||
state[feature]["feedback_frame"] = frame.public()
|
||||
|
||||
@staticmethod
|
||||
def _record_dynamic_state(
|
||||
state: dict[str, Any],
|
||||
values: dict[str, int],
|
||||
frame: CanFrame,
|
||||
) -> None:
|
||||
mode_id = int(values["Dynamic_Mode_ID"])
|
||||
dynamic_state = int(values["Dynamic_State"])
|
||||
names = {1: "木马模式", 2: "摇篮模式"}
|
||||
states = {0: "idle", 1: "entry", 2: "running", 3: "fault"}
|
||||
state["dynamic_mode"] = {
|
||||
"mode_id": mode_id,
|
||||
"name": names.get(mode_id, ""),
|
||||
"state": states.get(dynamic_state, "unknown"),
|
||||
"phase": int(values["Dynamic_Phase"]),
|
||||
"active_mask": int(values["Dynamic_Active_Mask"]),
|
||||
"fault_mask": int(values["Dynamic_Fault_Mask"]),
|
||||
"cycle_index": int(values["Dynamic_Cycle_Index"]),
|
||||
"phase_elapsed_ms": int(values["Dynamic_Phase_Elapsed_Ms"]),
|
||||
"repeat": "bounded_runtime" if dynamic_state in {1, 2} else "none",
|
||||
"max_runtime_ms": 600_000,
|
||||
"feedback_frame": frame.public(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _set_thermal_feature(
|
||||
state: dict[str, Any], feature: str, raw: int
|
||||
|
||||
@@ -20,28 +20,55 @@ STATE_PATTERN = re.compile(
|
||||
)
|
||||
LEVEL_PATTERN = re.compile(r"(最高|最低|[1-5一二三四五高中低])\s*档")
|
||||
POSITION_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:位置)?(?:调到|移动到|移到|设为|设置为|调至|移动至|到|为)?"
|
||||
r"{alias}(?:位置)?(?:调到|调至|移动到|移动至|移到|移至|"
|
||||
r"转动到|转动至|旋转到|旋转至|调节到|调节至|调整到|调整至|"
|
||||
r"设为|设置为|设置到|设置至|设置|设|到|为)?"
|
||||
r"(?P<position>[+-]?\d{{1,3}}(?:\.\d+)?)%"
|
||||
)
|
||||
ANGLE_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:夹角|角度)?(?:调到|放倒到|抬到|移动到|移到|设为|设置为|"
|
||||
r"调至|到|为)?(?P<angle>\d{{2,3}})(?:度|°)"
|
||||
r"{alias}(?:夹角|角度)?(?:调到|调至|放倒到|抬到|移动到|移动至|移到|移至|"
|
||||
r"转动到|转动至|旋转到|旋转至|调节到|调节至|调整到|调整至|"
|
||||
r"设为|设置为|设置到|设置至|设置|设|到|为)?"
|
||||
r"(?P<angle>\d{{2,3}})(?:度|°)"
|
||||
)
|
||||
RELATIVE_ANGLE_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:再|继续|接着|然后)?(?P<direction>向前|往前|朝前|前倾|向后|往后|朝后|后仰|"
|
||||
r"{alias}(?:再|继续|接着|然后)?"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
|
||||
r"(?:再|继续|接着|然后)?"
|
||||
r"(?P<direction>向前|往前|朝前|前倾|向后|往后|朝后|后仰|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
|
||||
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})(?:度|°)"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
|
||||
r"(?P<delta>\d{{1,3}})(?:度|°)"
|
||||
)
|
||||
RELATIVE_PERCENT_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:再|继续|接着|然后)?(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
|
||||
r"{alias}(?:再|继续|接着|然后)?"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
|
||||
r"(?:再|继续|接着|然后)?"
|
||||
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
|
||||
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})%"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
|
||||
r"(?P<delta>\d{{1,3}})%"
|
||||
)
|
||||
DIRECTIONAL_ABSOLUTE_PERCENT_PATTERN = re.compile(
|
||||
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)*"
|
||||
r"(?:到|至|为)"
|
||||
r"(?P<position>[+-]?\d{1,3}(?:\.\d+)?)%"
|
||||
)
|
||||
DIRECTIONAL_RELATIVE_PERCENT_PATTERN = re.compile(
|
||||
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
|
||||
r"(?P<delta>\d{1,3})%"
|
||||
)
|
||||
TIMED_MOTION_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:持续|保持)?(?:再|继续|接着|然后)?"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|倾斜|抬|升|降)?"
|
||||
r"(?:再|继续|接着|然后)?"
|
||||
r"(?P<direction>向前|往前|朝前|前移|前倾|向后|往后|朝后|后移|后仰|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
|
||||
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降|运行|运动|持续|保持|时长)*"
|
||||
r"(?:移动|移|转动|旋转|转|调节|调整|调|倾斜|抬|升|降|运行|运动|持续|保持|时长)*"
|
||||
r"(?P<duration>\d+(?:\.\d+)?)(?P<unit>毫秒|ms|秒|s|分钟|分|min)"
|
||||
)
|
||||
ZERO_POSITION_PATTERN = re.compile(r"归零|回零|复位|零位")
|
||||
@@ -126,7 +153,14 @@ MOTION_AXIS_ALIASES = {
|
||||
"longitudinal": ("水平", "前后", "纵向", "滑轨"),
|
||||
# Internal capability name is retained for compatibility. On the
|
||||
# seat-ota CAN0 firmware this board axis is the physical seat-height axis.
|
||||
"cushion_tilt": ("座椅高度", "高度", "坐垫倾角", "坐盆", "坐垫"),
|
||||
"cushion_tilt": (
|
||||
"座椅高度",
|
||||
"高度",
|
||||
"坐垫倾角",
|
||||
"坐盆倾角",
|
||||
"坐盆",
|
||||
"坐垫",
|
||||
),
|
||||
"backrest_recline": ("靠背", "椅背"),
|
||||
"legrest_height": ("腿部支撑", "小腿托", "腿托", "脚托"),
|
||||
}
|
||||
@@ -222,6 +256,7 @@ def _normalize_text(value: str) -> str:
|
||||
text = re.sub(r"座椅(?:吹风|送风)", "座椅通风", text)
|
||||
text = re.sub(r"座椅(?:取暖|加温)", "座椅加热", text)
|
||||
text = text.replace("座椅暖一下", "座椅加热打开")
|
||||
text = text.replace("推托", "腿托")
|
||||
|
||||
def replace_percent(match: re.Match[str]) -> str:
|
||||
number = _chinese_integer(match.group(1))
|
||||
@@ -251,6 +286,74 @@ def _relative_direction(axis: str, token: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _directional_motion_axis(clause: str, direction: str) -> str | None:
|
||||
"""Resolve an omitted passenger-seat axis from the movement direction."""
|
||||
|
||||
explicit_axes = [
|
||||
axis
|
||||
for axis, aliases in MOTION_AXIS_ALIASES.items()
|
||||
if any(alias in clause for alias in aliases)
|
||||
]
|
||||
if len(explicit_axes) == 1:
|
||||
return explicit_axes[0]
|
||||
if explicit_axes:
|
||||
return None
|
||||
if TARGET_PATTERN.search(clause) is None and not re.search(r"座椅|座位", clause):
|
||||
return None
|
||||
if direction in {
|
||||
"向前",
|
||||
"往前",
|
||||
"朝前",
|
||||
"前移",
|
||||
"向后",
|
||||
"往后",
|
||||
"朝后",
|
||||
"后移",
|
||||
}:
|
||||
return "longitudinal"
|
||||
if direction in {
|
||||
"向上",
|
||||
"往上",
|
||||
"抬高",
|
||||
"抬起",
|
||||
"升高",
|
||||
"向下",
|
||||
"往下",
|
||||
"降低",
|
||||
"降下",
|
||||
}:
|
||||
return "cushion_tilt"
|
||||
return None
|
||||
|
||||
|
||||
def _absolute_percent_motion_action(
|
||||
axis: str, requested_position_value: float
|
||||
) -> dict[str, Any]:
|
||||
requested_position_pct = _round_half_away_from_zero(requested_position_value)
|
||||
position_pct = max(0, min(100, requested_position_pct))
|
||||
action: dict[str, Any] = {
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": axis,
|
||||
"position_pct": position_pct,
|
||||
}
|
||||
notices: list[str] = []
|
||||
if requested_position_value != requested_position_pct:
|
||||
notices.append(
|
||||
f"{MOTION_AXIS_LABELS[axis]}请求位置"
|
||||
f"{_format_numeric_request(requested_position_value)}%;"
|
||||
f"已取整为{requested_position_pct}%。"
|
||||
)
|
||||
if requested_position_pct != position_pct:
|
||||
notices.append(
|
||||
f"取整后超过0到100%行程;已采用{position_pct}%的备用目标"
|
||||
"并移动到对应机械末端。"
|
||||
)
|
||||
if notices:
|
||||
action["user_notice"] = " ".join(notices)
|
||||
return action
|
||||
|
||||
|
||||
def _context_motion_state(
|
||||
context: dict[str, Any] | None, axis: str
|
||||
) -> dict[str, Any] | None:
|
||||
@@ -982,6 +1085,32 @@ def parse_local_intent(
|
||||
if shared_position is not None:
|
||||
continue
|
||||
|
||||
directional_absolute_axes: set[str] = set()
|
||||
directional_absolute_match = DIRECTIONAL_ABSOLUTE_PERCENT_PATTERN.search(clause)
|
||||
if directional_absolute_match is not None:
|
||||
axis = _directional_motion_axis(
|
||||
clause, directional_absolute_match.group("direction")
|
||||
)
|
||||
if axis is not None:
|
||||
directional_absolute_axes.add(axis)
|
||||
target_zh = (
|
||||
_nearest_preceding_target(
|
||||
clause, directional_absolute_match.start()
|
||||
)
|
||||
or "副驾"
|
||||
)
|
||||
if "driver" in TARGETS[target_zh]:
|
||||
ambiguities.append(
|
||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||
)
|
||||
else:
|
||||
motion_actions.append(
|
||||
_absolute_percent_motion_action(
|
||||
axis,
|
||||
float(directional_absolute_match.group("position")),
|
||||
)
|
||||
)
|
||||
|
||||
timed_axes: set[str] = set()
|
||||
timed_aliases = {
|
||||
**MOTION_AXIS_ALIASES,
|
||||
@@ -1022,7 +1151,7 @@ def parse_local_intent(
|
||||
|
||||
relative_axes: set[str] = set()
|
||||
for axis, aliases in MOTION_AXIS_ALIASES.items():
|
||||
if axis in timed_axes:
|
||||
if axis in timed_axes or axis in directional_absolute_axes:
|
||||
continue
|
||||
for alias in aliases:
|
||||
angle_match = re.search(
|
||||
@@ -1062,6 +1191,46 @@ def parse_local_intent(
|
||||
motion_actions.append(action)
|
||||
break
|
||||
|
||||
directional_relative_match = DIRECTIONAL_RELATIVE_PERCENT_PATTERN.search(clause)
|
||||
if (
|
||||
directional_relative_match is not None
|
||||
and not relative_axes
|
||||
and not directional_absolute_axes
|
||||
):
|
||||
axis = _directional_motion_axis(
|
||||
clause, directional_relative_match.group("direction")
|
||||
)
|
||||
if axis is not None and axis not in timed_axes:
|
||||
relative_axes.add(axis)
|
||||
target_zh = (
|
||||
_nearest_preceding_target(
|
||||
clause, directional_relative_match.start()
|
||||
)
|
||||
or "副驾"
|
||||
)
|
||||
if "driver" in TARGETS[target_zh]:
|
||||
ambiguities.append(
|
||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||
)
|
||||
else:
|
||||
action, question = _relative_motion_action(
|
||||
axis,
|
||||
directional_relative_match.group("direction"),
|
||||
int(directional_relative_match.group("delta")),
|
||||
"percent",
|
||||
context,
|
||||
)
|
||||
if action is None:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"passenger.position.{axis}",
|
||||
"question": question
|
||||
or "无法计算座椅相对移动目标。",
|
||||
}
|
||||
)
|
||||
else:
|
||||
motion_actions.append(action)
|
||||
|
||||
endpoint_actions: dict[str, int] = {}
|
||||
has_specific_non_longitudinal_axis = any(
|
||||
any(alias in clause for alias in MOTION_AXIS_ALIASES[axis])
|
||||
@@ -1078,7 +1247,7 @@ def parse_local_intent(
|
||||
endpoint_actions["longitudinal"] = 0
|
||||
elif re.search(r"最后面|最后|向后移到底|移到最后", clause):
|
||||
endpoint_actions["longitudinal"] = 100
|
||||
cushion_alias = r"(?:座椅高度|高度|坐垫倾角|坐盆|坐垫)"
|
||||
cushion_alias = r"(?:座椅高度|高度|坐垫倾角|坐盆倾角|坐盆|坐垫)"
|
||||
if re.search(
|
||||
cushion_alias + r"(?:抬到最上方|调到最上方|移到最上方|最高|抬到头|抬到底)",
|
||||
clause,
|
||||
@@ -1128,7 +1297,12 @@ def parse_local_intent(
|
||||
)
|
||||
|
||||
for axis, aliases in MOTION_AXIS_ALIASES.items():
|
||||
if axis in endpoint_actions or axis in relative_axes or axis in timed_axes:
|
||||
if (
|
||||
axis in endpoint_actions
|
||||
or axis in relative_axes
|
||||
or axis in timed_axes
|
||||
or axis in directional_absolute_axes
|
||||
):
|
||||
continue
|
||||
for alias in aliases:
|
||||
position_match = re.search(
|
||||
|
||||
@@ -74,6 +74,9 @@ class LocalIntentParserTests(unittest.TestCase):
|
||||
"把主驾和副驾温度都设为24度",
|
||||
"风量调到2档",
|
||||
"设置主驾出风模式为吹脸",
|
||||
"把副驾车窗移动到30%",
|
||||
"将主驾空调温度设置为24度",
|
||||
"把遮阳帘转动到20%",
|
||||
)
|
||||
for utterance in utterances:
|
||||
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]["motion_intent"]["direction"], "decrease")
|
||||
|
||||
def test_parses_passenger_directional_relative_percentages(self) -> None:
|
||||
context = self.motion_context()
|
||||
for state in context["devices"]["passenger"]["position"].values():
|
||||
state["position_pct"] = 50
|
||||
cases = (
|
||||
("副驾座椅往上移动10%", "cushion_tilt", "decrease", 10),
|
||||
("副驾座椅往上移动20%", "cushion_tilt", "decrease", 20),
|
||||
("副驾往下移动10%", "cushion_tilt", "increase", 10),
|
||||
("副驾往下移动20%", "cushion_tilt", "increase", 20),
|
||||
("副驾座椅往前移动10%", "longitudinal", "decrease", 10),
|
||||
("副驾座椅往前移动20%", "longitudinal", "decrease", 20),
|
||||
("副驾往后移动10%", "longitudinal", "increase", 10),
|
||||
("副驾往后移动20%", "longitudinal", "increase", 20),
|
||||
("副驾座椅靠背往前移动10%", "backrest_recline", "decrease", 10),
|
||||
("副驾座椅靠背往前移动30%", "backrest_recline", "decrease", 30),
|
||||
("副驾靠背往后移动10%", "backrest_recline", "increase", 10),
|
||||
("副驾靠背往后移动30%", "backrest_recline", "increase", 30),
|
||||
("副驾推托往上移动20%", "legrest_height", "increase", 20),
|
||||
("副驾推托往上移动40%", "legrest_height", "increase", 40),
|
||||
("副驾推托往下移动20%", "legrest_height", "decrease", 20),
|
||||
("副驾推托往下移动40%", "legrest_height", "decrease", 40),
|
||||
)
|
||||
|
||||
for utterance, axis, direction, delta in cases:
|
||||
with self.subTest(utterance=utterance):
|
||||
result = parse_local_intent(utterance, context)
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(len(result["actions"]), 1)
|
||||
action = result["actions"][0]
|
||||
self.assertEqual(action["axis"], axis)
|
||||
self.assertEqual(action["motion_intent"]["mode"], "relative")
|
||||
self.assertEqual(action["motion_intent"]["direction"], direction)
|
||||
self.assertEqual(action["motion_intent"]["delta_position_pct"], delta)
|
||||
|
||||
def test_motion_verbs_are_weak_signals_for_relative_seat_controls(self) -> None:
|
||||
context = self.motion_context()
|
||||
for state in context["devices"]["passenger"]["position"].values():
|
||||
state["position_pct"] = 50
|
||||
cases = (
|
||||
("副驾座椅往前移动10%", "longitudinal", "decrease", 10),
|
||||
("副驾靠背往后转动20%", "backrest_recline", "increase", 20),
|
||||
("副驾腿托往上调节30%", "legrest_height", "increase", 30),
|
||||
("副驾座椅高度往下设置10%", "cushion_tilt", "increase", 10),
|
||||
("副驾靠背转动往前20%", "backrest_recline", "decrease", 20),
|
||||
("副驾腿托设置往下30%", "legrest_height", "decrease", 30),
|
||||
("副驾靠背往后旋转10度", "backrest_recline", "increase", 10),
|
||||
)
|
||||
|
||||
for utterance, axis, direction, amount in cases:
|
||||
with self.subTest(utterance=utterance):
|
||||
result = parse_local_intent(utterance, context)
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(len(result["actions"]), 1)
|
||||
action = result["actions"][0]
|
||||
self.assertEqual(action["axis"], axis)
|
||||
self.assertEqual(action["motion_intent"]["mode"], "relative")
|
||||
self.assertEqual(action["motion_intent"]["direction"], direction)
|
||||
motion = action["motion_intent"]
|
||||
requested = motion.get("requested_delta_angle_deg")
|
||||
if requested is None:
|
||||
requested = motion.get("requested_delta_position_pct")
|
||||
if requested is None:
|
||||
requested = motion.get(
|
||||
"delta_angle_deg", motion.get("delta_position_pct")
|
||||
)
|
||||
self.assertEqual(requested, amount)
|
||||
|
||||
def test_directional_move_to_percentage_is_absolute_not_relative(self) -> None:
|
||||
cases = (
|
||||
("副驾座椅往上移动到10%", "cushion_tilt", 10),
|
||||
("副驾座椅往上移动到20%", "cushion_tilt", 20),
|
||||
("副驾往下移动到10%", "cushion_tilt", 10),
|
||||
("副驾往下移动到20%", "cushion_tilt", 20),
|
||||
("副驾座椅往前移动到10%", "longitudinal", 10),
|
||||
("副驾座椅往前移动到20%", "longitudinal", 20),
|
||||
("副驾往后移动到10%", "longitudinal", 10),
|
||||
("副驾往后移动到20%", "longitudinal", 20),
|
||||
("副驾座椅靠背往前移动到10%", "backrest_recline", 10),
|
||||
("副驾座椅靠背往前移动到30%", "backrest_recline", 30),
|
||||
("副驾靠背往后移动到10%", "backrest_recline", 10),
|
||||
("副驾靠背往后移动到30%", "backrest_recline", 30),
|
||||
("副驾推托往上移动到20%", "legrest_height", 20),
|
||||
("副驾推托往上移动到40%", "legrest_height", 40),
|
||||
("副驾推托往下移动到20%", "legrest_height", 20),
|
||||
("副驾推托往下移动到40%", "legrest_height", 40),
|
||||
)
|
||||
|
||||
for utterance, axis, position in cases:
|
||||
with self.subTest(utterance=utterance):
|
||||
result = parse_local_intent(utterance, self.motion_context())
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(len(result["actions"]), 1)
|
||||
action = result["actions"][0]
|
||||
self.assertEqual(action["axis"], axis)
|
||||
self.assertEqual(action["position_pct"], position)
|
||||
self.assertNotIn("motion_intent", action)
|
||||
|
||||
def test_motion_verbs_with_position_markers_are_absolute(self) -> None:
|
||||
cases = (
|
||||
("副驾滑轨移动至60%", "longitudinal", 60),
|
||||
("副驾靠背转动到30%", "backrest_recline", 30),
|
||||
("副驾腿托调节至40%", "legrest_height", 40),
|
||||
("副驾座椅高度设置为20%", "cushion_tilt", 20),
|
||||
("副驾靠背往后转动到35%", "backrest_recline", 35),
|
||||
("副驾腿托往上设置为45%", "legrest_height", 45),
|
||||
("副驾靠背旋转到130度", "backrest_recline", 51),
|
||||
)
|
||||
|
||||
for utterance, axis, position in cases:
|
||||
with self.subTest(utterance=utterance):
|
||||
result = parse_local_intent(utterance, self.motion_context())
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(len(result["actions"]), 1)
|
||||
action = result["actions"][0]
|
||||
self.assertEqual(action["axis"], axis)
|
||||
self.assertEqual(action["position_pct"], position)
|
||||
self.assertNotIn("motion_intent", action)
|
||||
|
||||
def test_rejects_relative_motion_without_state_and_clamps_endpoint_overflow(self) -> None:
|
||||
missing = parse_local_intent("靠背向前移动10度")
|
||||
context = self.motion_context()
|
||||
|
||||
@@ -239,6 +239,9 @@ class ConversationStateStore:
|
||||
with self._lock:
|
||||
state = self._state(conversation_id)
|
||||
passenger = state["devices"]["passenger"]
|
||||
feedback_source = str(physical_state.get("source", "physical_can"))
|
||||
if feedback_source not in {"physical_can", "wireless_mqtt_ch0"}:
|
||||
feedback_source = "physical_can"
|
||||
for axis, feedback in physical_state.get("position", {}).items():
|
||||
if axis not in MOTION_INITIAL_POSITIONS or not isinstance(
|
||||
feedback, dict
|
||||
@@ -260,7 +263,7 @@ class ConversationStateStore:
|
||||
position_state["position_valid"] = bool(
|
||||
feedback["position_valid"]
|
||||
)
|
||||
position_state["feedback_source"] = "physical_can"
|
||||
position_state["feedback_source"] = feedback_source
|
||||
passenger["position"][str(axis)] = position_state
|
||||
|
||||
for feature in THERMAL_FUNCTIONS:
|
||||
@@ -277,7 +280,20 @@ class ConversationStateStore:
|
||||
passenger[feature] = {
|
||||
"state": state_value,
|
||||
"level": level,
|
||||
"feedback_source": "physical_can",
|
||||
"feedback_source": feedback_source,
|
||||
}
|
||||
|
||||
dynamic_mode = physical_state.get("dynamic_mode")
|
||||
if isinstance(dynamic_mode, dict):
|
||||
dynamic_state = str(dynamic_mode.get("state", "idle"))
|
||||
passenger["dynamic_mode"] = {
|
||||
**deepcopy(dynamic_mode),
|
||||
"state": (
|
||||
"running"
|
||||
if dynamic_state in {"entry", "running"}
|
||||
else dynamic_state
|
||||
),
|
||||
"feedback_source": feedback_source,
|
||||
}
|
||||
|
||||
state["physical_state_updated_at"] = str(
|
||||
|
||||
@@ -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"])
|
||||
parameters = operation.get("parameters", {})
|
||||
axes = {
|
||||
"seat.set_longitudinal_position": "longitudinal",
|
||||
"seat.set_cushion_tilt": "height",
|
||||
"seat.set_longitudinal_position": "horizontal",
|
||||
"seat.set_cushion_tilt": "basin",
|
||||
"seat.set_backrest_recline": "backrest",
|
||||
"seat.set_legrest_height": "legrest",
|
||||
}
|
||||
if capability in axes:
|
||||
motion = operation.get("motion", {})
|
||||
relative_directions = {
|
||||
"horizontal": {"increase": "backward", "decrease": "forward"},
|
||||
"basin": {"increase": "down", "decrease": "up"},
|
||||
"backrest": {"increase": "backward", "decrease": "forward"},
|
||||
"legrest": {"increase": "up", "decrease": "down"},
|
||||
}
|
||||
direction = relative_directions[axes[capability]].get(
|
||||
str(motion.get("direction", ""))
|
||||
)
|
||||
delta = motion.get("delta_position_pct")
|
||||
if motion.get("mode") == "relative" and direction and delta is not None:
|
||||
return (
|
||||
f"seat passenger {axes[capability]} relative "
|
||||
f"{direction} {int(delta)}"
|
||||
)
|
||||
return (
|
||||
f"seat passenger {axes[capability]} position "
|
||||
f"{parameters['position_pct']}"
|
||||
)
|
||||
if capability in {"seat.set_ventilation", "seat.set_heating"}:
|
||||
feature = "vent" if capability.endswith("ventilation") else "heat"
|
||||
if parameters.get("state") == "off":
|
||||
return f"seat passenger {feature} off"
|
||||
level = {1: "low", 2: "medium", 3: "high"}.get(
|
||||
int(parameters.get("level", 1)), "low"
|
||||
)
|
||||
return f"seat passenger {feature} {level}"
|
||||
if capability == "seat.start_dynamic_mode":
|
||||
mode = "rocking-horse" if int(parameters["mode_id"]) == 1 else "cradle"
|
||||
return f"seat passenger dynamic start {mode}"
|
||||
return f"seat passenger dynamic {mode}"
|
||||
if capability == "seat.stop_dynamic_mode":
|
||||
return "seat passenger dynamic stop"
|
||||
return capability
|
||||
@@ -384,6 +408,7 @@ class MqttOtaSeatExecutor:
|
||||
try:
|
||||
bus = self.bus_factory()
|
||||
previous_offset = 0
|
||||
thermal_publication_start: int | None = None
|
||||
for command in command_plan["spec"]["commands"]:
|
||||
offset = int(command.get("send_offset_ms", 0))
|
||||
if offset > previous_offset:
|
||||
@@ -393,8 +418,10 @@ class MqttOtaSeatExecutor:
|
||||
operations[item]
|
||||
for item in command["operation_ref"].split(",")
|
||||
]
|
||||
publication_start = len(
|
||||
getattr(bus, "published_messages", ())
|
||||
publication_start = (
|
||||
thermal_publication_start
|
||||
if thermal_publication_start is not None
|
||||
else len(getattr(bus, "published_messages", ()))
|
||||
)
|
||||
set_debug_command = getattr(bus, "set_debug_command", None)
|
||||
if callable(set_debug_command):
|
||||
@@ -404,24 +431,40 @@ class MqttOtaSeatExecutor:
|
||||
for item in referenced
|
||||
)
|
||||
)
|
||||
if command["message"] != "T2G_Seat_Move_Command_300":
|
||||
raise HardwareExecutionError(
|
||||
"seat-ota firmware does not provide wireless thermal control; "
|
||||
f"{command['message']} was not sent"
|
||||
if command["message"] == "T2G_Seat_Thermal_Mode_3CE":
|
||||
encoded = self.status_executor.matrix.encode(
|
||||
command["message"], command["signals"]
|
||||
)
|
||||
opcode = int(command["signals"]["Opcode"])
|
||||
if opcode in {0x0E, 0x0F}:
|
||||
motion_armed = opcode == 0x0E
|
||||
command_results = self._execute_dynamic(
|
||||
bus.send(
|
||||
int(encoded["frame_id"], 16),
|
||||
bytes.fromhex(encoded["data_hex"]),
|
||||
)
|
||||
thermal_publication_start = publication_start
|
||||
continue
|
||||
if command["message"] == "T2G_Seat_Thermal_Control_29E":
|
||||
command_results = self.status_executor._execute_thermal( # noqa: SLF001
|
||||
bus, command, referenced
|
||||
)
|
||||
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:
|
||||
motion_armed = True
|
||||
command_results = self._execute_move(
|
||||
bus, command, referenced
|
||||
raise HardwareExecutionError(
|
||||
"wireless CAN0 command is not in the passenger-seat runtime "
|
||||
f"whitelist: {command['message']}"
|
||||
)
|
||||
motion_armed = False
|
||||
mqtt_messages = list(
|
||||
getattr(bus, "published_messages", ())[
|
||||
publication_start:
|
||||
@@ -491,6 +534,7 @@ class MqttOtaSeatExecutor:
|
||||
"labels": {
|
||||
"simulation": "false",
|
||||
"feedback": "wireless_mqtt_ch0",
|
||||
"seat_feedback": "wireless_mqtt_ch0",
|
||||
},
|
||||
},
|
||||
"spec": spec,
|
||||
|
||||
@@ -60,6 +60,65 @@ LIGHT_TARGETS = {
|
||||
"右后": "rear_right",
|
||||
}
|
||||
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
|
||||
# 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.
|
||||
"""
|
||||
|
||||
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(
|
||||
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]
|
||||
if len(present_thermal) == 1:
|
||||
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
|
||||
if re.search(r"关闭|关掉|停止|取消|不要|别开", compact):
|
||||
if re.search(r"关闭|关掉|停止|取消|不要|别开", thermal_text):
|
||||
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}
|
||||
elif re.search(r"低档|低速|最低|弱档|调低|2档", compact):
|
||||
elif thermal_grade == "low" or re.search(
|
||||
r"低速|(?:2|二)(?:档|级)", thermal_text
|
||||
):
|
||||
parameters = {"state": "on", "level": 2}
|
||||
elif re.search(r"打开|开启|启动", compact):
|
||||
elif re.search(r"打开|开启|启动|启用", thermal_text):
|
||||
parameters = {"state": "on", "level": 2}
|
||||
if parameters is not None:
|
||||
actions.append(_action(
|
||||
@@ -531,9 +626,10 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
|
||||
))
|
||||
|
||||
if "按摩" in compact:
|
||||
massage: dict[str, Any] | None = None
|
||||
if re.search(r"关闭|关掉|停止|取消", compact):
|
||||
massage = {"action": "off"}
|
||||
massage_text = _feature_context(compact, "按摩")
|
||||
massage_actions: list[dict[str, Any]] = []
|
||||
if re.search(r"关闭|关掉|停止|取消|不要|别开", massage_text):
|
||||
massage_actions.append({"action": "off"})
|
||||
else:
|
||||
modes = (
|
||||
(r"波浪", "wave"),
|
||||
@@ -543,67 +639,122 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
|
||||
(r"舒展|放松", "relax"),
|
||||
)
|
||||
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,
|
||||
)
|
||||
if mode is not None:
|
||||
massage = {"action": "mode", "mode": mode}
|
||||
elif re.search(r"强度|力度|弱|中等|中档|强|[123]档", compact):
|
||||
strength = (
|
||||
1
|
||||
if re.search(r"弱|低|1档", compact)
|
||||
else 2
|
||||
if re.search(r"中等|中档|2档", compact)
|
||||
else 3
|
||||
if re.search(r"强(?!度)|高|3档", compact)
|
||||
else 2
|
||||
massage_actions.append({"action": "mode", "mode": mode})
|
||||
elif re.search(r"打开|开启|启动|启用", massage_text):
|
||||
# The verified vocabulary has no state-only ON frame. Starting
|
||||
# the conservative relax mode is the deterministic ON action.
|
||||
massage_actions.append({"action": "mode", "mode": "relax"})
|
||||
|
||||
massage_grade = _grade_alias(massage_text)
|
||||
strength_requested = bool(
|
||||
re.search(
|
||||
r"强度|力度|档位|等级|最小|最大|最低|最高|最弱|最强|"
|
||||
r"弱|中等|中档|中级|中间|适中|强(?!度)|"
|
||||
r"(?:[123一二三])(?:档|级)",
|
||||
massage_text,
|
||||
)
|
||||
massage = {"action": "strength", "strength": strength}
|
||||
if massage is not None:
|
||||
actions.append(_action(
|
||||
"seat",
|
||||
"seat.set_driver_massage_native",
|
||||
"driver",
|
||||
massage,
|
||||
(
|
||||
"用户明确指定了 CH1 主驾座椅按摩控制。"
|
||||
if explicit_driver
|
||||
else default_reason
|
||||
),
|
||||
))
|
||||
)
|
||||
if strength_requested and massage_grade is not None:
|
||||
massage_actions.append(
|
||||
{
|
||||
"action": "strength",
|
||||
"strength": {"low": 1, "medium": 2, "high": 3}[
|
||||
massage_grade
|
||||
],
|
||||
}
|
||||
)
|
||||
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
|
||||
direction: str | None = None
|
||||
if re.search(r"坐盆|坐垫", compact):
|
||||
if re.search(r"坐盆|坐垫|座垫", compact):
|
||||
axis = "basin"
|
||||
direction = (
|
||||
"stop" if re.search(r"停止|停下|暂停", compact)
|
||||
else "up" if re.search(r"抬高|升高|向上|往上", compact)
|
||||
else "down" if re.search(r"降低|下降|向下|往下", compact)
|
||||
else "up" if re.search(r"抬高|抬起|升高|翘起|上调|向上|往上", compact)
|
||||
else "down" if re.search(r"降低|下降|降下|放低|下调|向下|往下", compact)
|
||||
else None
|
||||
)
|
||||
elif "靠背" in compact:
|
||||
elif re.search(r"靠背|椅背", compact):
|
||||
axis = "backrest"
|
||||
direction = (
|
||||
"stop" if re.search(r"停止|停下|暂停", compact)
|
||||
else "backward" if re.search(r"后仰|向后|往后", compact)
|
||||
else "forward" if re.search(r"前倾|向前|往前|立直", compact)
|
||||
else "backward" if re.search(r"后仰|放倒|躺倒|调躺|后倾|向后|往后|朝后", compact)
|
||||
else "forward" if re.search(r"前倾|调直|立直|竖直|竖起|立起|向前|往前|朝前", compact)
|
||||
else None
|
||||
)
|
||||
elif re.search(r"座椅高度|整椅|座椅升高|座椅降低|座椅调高|座椅调低", compact):
|
||||
elif re.search(
|
||||
r"座椅高度|整椅|座椅升高|座椅降低|座椅调高|座椅调低|"
|
||||
r"座椅(?:向|往|朝)?(?:上|下)(?:移|移动|调|调整)?|座椅上移|座椅下移",
|
||||
compact,
|
||||
):
|
||||
axis = "height"
|
||||
direction = (
|
||||
"stop" if re.search(r"停止|停下|暂停", compact)
|
||||
else "up" if re.search(r"抬高|升高|上升|向上|往上|调高", compact)
|
||||
else "down" if re.search(r"降低|下降|向下|往下|调低", compact)
|
||||
else "up" if re.search(r"抬高|抬起|升高|上升|上移|向上|往上|朝上|调高", compact)
|
||||
else "down" if re.search(r"降低|下降|降下|下移|向下|往下|朝下|调低|放低", compact)
|
||||
else None
|
||||
)
|
||||
elif re.search(r"座椅前后|水平|前移|后移|向前移动|向后移动|座椅(?:向|往)?(?:前|后)", compact):
|
||||
elif re.search(
|
||||
r"座椅前后|水平|滑轨|前移|后移|前进|后退|前推|后拉|"
|
||||
r"(?:向|往|朝)(?:前|后)(?:移动|挪动|挪|调节|调整|调|滑动)?|"
|
||||
r"座椅(?:向|往|朝)?(?:前|后)",
|
||||
compact,
|
||||
):
|
||||
axis = "horizontal"
|
||||
direction = (
|
||||
"stop" if re.search(r"停止|停下|暂停", compact)
|
||||
else "forward" if re.search(r"前移|向前|往前", compact)
|
||||
else "backward" if re.search(r"后移|向后|往后", compact)
|
||||
else "forward" if re.search(r"前移|前进|前推|向前|往前|朝前", compact)
|
||||
else "backward" if re.search(r"后移|后退|后拉|向后|往后|朝后", compact)
|
||||
else 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(
|
||||
r"(\d+(?:\.\d+)?)(?:秒|s)", compact, re.IGNORECASE
|
||||
)
|
||||
chinese_second_match = re.search(r"(半|一|二|两)(?:秒|s)", 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
|
||||
duration = 500
|
||||
if direction != "stop" and millisecond_match:
|
||||
duration = int(millisecond_match[1])
|
||||
elif direction != "stop" and second_match:
|
||||
duration = round(float(second_match[1]) * 1000)
|
||||
elif direction != "stop" and chinese_second_match:
|
||||
duration = {"半": 500, "一": 1000, "二": 2000, "两": 2000}[
|
||||
chinese_second_match[1]
|
||||
]
|
||||
if (
|
||||
not unsupported_absolute_goal
|
||||
and not unsupported_duration
|
||||
@@ -652,6 +811,111 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
|
||||
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]]:
|
||||
compact = re.sub(r"\s+", "", text)
|
||||
seat_text = _scoped_text(
|
||||
@@ -726,8 +990,14 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
|
||||
r"([1-7])(?:档|级)?(?![\d.])",
|
||||
climate_text,
|
||||
)
|
||||
if fan:
|
||||
actions.append(_action("climate", "climate.set_fan_level", "vehicle", {"level": int(fan[1])}, "用户指定了空调风量。"))
|
||||
fan_level = int(fan[1]) if fan else None
|
||||
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):
|
||||
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):
|
||||
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。"))
|
||||
|
||||
has_face = bool(re.search(r"吹面|吹脸", climate_text))
|
||||
has_feet = "吹脚" in climate_text
|
||||
has_defrost = "除霜" in climate_text
|
||||
has_face = _positive_outlet_feature(climate_text, r"吹面|吹脸")
|
||||
has_feet = _positive_outlet_feature(climate_text, r"吹脚")
|
||||
has_defrost = _positive_outlet_feature(climate_text, r"除霜")
|
||||
outlet_mode = (
|
||||
"face_feet_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(
|
||||
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
|
||||
)
|
||||
actions.extend(_parse_chinese_window_commands(compact))
|
||||
|
||||
door_text = _scoped_text(compact, r"门锁|车门|后门|锁车|解锁车辆")
|
||||
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:
|
||||
actions.append(_action("ambient_light", "ambient_light.set_color_index", "vehicle", {"color_index": int(color_match[1])}, "用户指定了已验证的氛围灯颜色索引。"))
|
||||
if brightness_match:
|
||||
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": int(brightness_match[1])}, "用户指定了已验证的氛围灯亮度档位。"))
|
||||
brightness_level = int(brightness_match[1]) if brightness_match else None
|
||||
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_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,
|
||||
)
|
||||
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
|
||||
|
||||
result = dict(parsed)
|
||||
@@ -1391,6 +1701,20 @@ def merge_native_can_intent(
|
||||
}
|
||||
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"] = [
|
||||
item
|
||||
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 not (
|
||||
resolved_separate_thermal_and_massage
|
||||
and str(item.get("field", "")) == "passenger.thermal.state"
|
||||
)
|
||||
]
|
||||
existing_summaries = list(result.get("decision_summary", []))
|
||||
if unique_actions and not forbidden_seat_scope and not result.get("actions"):
|
||||
|
||||
@@ -57,6 +57,9 @@ from cognitive_agent.application.mqtt_ota_seat_executor import ( # noqa: E402
|
||||
MqttOtaSeatExecutor,
|
||||
)
|
||||
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
|
||||
NativeCanRelativeResolver,
|
||||
)
|
||||
@@ -1401,6 +1404,47 @@ class SeatDemoService:
|
||||
}
|
||||
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(
|
||||
self,
|
||||
utterance: str,
|
||||
@@ -1420,6 +1464,7 @@ class SeatDemoService:
|
||||
_model_parse_source: str = "cloud_model",
|
||||
_allow_empty_control_result: bool = False,
|
||||
_on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
|
||||
_direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
control_transport = self._validate_control_transport(control_transport)
|
||||
# Creating a reusable scene is a configuration workflow. Synthesize
|
||||
@@ -1444,6 +1489,28 @@ class SeatDemoService:
|
||||
_model_parse_source,
|
||||
_allow_empty_control_result,
|
||||
_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(
|
||||
control_transport
|
||||
@@ -1464,6 +1531,7 @@ class SeatDemoService:
|
||||
_model_parse_source,
|
||||
_allow_empty_control_result,
|
||||
_on_commands_validated,
|
||||
_direct_can_batch,
|
||||
)
|
||||
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
|
||||
with self.hardware_execution.reserve():
|
||||
@@ -1483,6 +1551,7 @@ class SeatDemoService:
|
||||
_model_parse_source,
|
||||
_allow_empty_control_result,
|
||||
_on_commands_validated,
|
||||
_direct_can_batch,
|
||||
)
|
||||
|
||||
def _process_once(
|
||||
@@ -1501,6 +1570,7 @@ class SeatDemoService:
|
||||
model_parse_source: str = "cloud_model",
|
||||
allow_empty_control_result: bool = False,
|
||||
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
|
||||
direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
control_transport = self._validate_control_transport(control_transport)
|
||||
utterance = utterance.strip()
|
||||
@@ -1569,9 +1639,13 @@ class SeatDemoService:
|
||||
vehicle_profile,
|
||||
)
|
||||
context["mode_profiles"] = self.mode_profiles.public_modes(vehicle_profile.id)
|
||||
matched_modes = self.mode_profiles.match_text(
|
||||
utterance,
|
||||
vehicle_id=vehicle_profile.id,
|
||||
matched_modes = (
|
||||
[]
|
||||
if direct_can_batch is not None
|
||||
else self.mode_profiles.match_text(
|
||||
utterance,
|
||||
vehicle_id=vehicle_profile.id,
|
||||
)
|
||||
)
|
||||
traces: list[dict[str, Any]] = []
|
||||
result: dict[str, Any] = {
|
||||
@@ -1614,6 +1688,9 @@ class SeatDemoService:
|
||||
for item in context.get("presets", [])
|
||||
],
|
||||
"trace_events": traces,
|
||||
"can_commands": (
|
||||
list(direct_can_batch[0]) if direct_can_batch is not None else []
|
||||
),
|
||||
}
|
||||
|
||||
started = time.perf_counter()
|
||||
@@ -1652,40 +1729,45 @@ class SeatDemoService:
|
||||
result,
|
||||
traces,
|
||||
)
|
||||
parsed, provider_used, provider_notice = self._interpret(
|
||||
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)
|
||||
if direct_can_batch is not None:
|
||||
parsed = deepcopy(direct_can_batch[1])
|
||||
provider_used = "direct_can_control"
|
||||
provider_notice = None
|
||||
else:
|
||||
parsed = self._apply_mode_profiles(
|
||||
parsed, matched_modes, context, vehicle_profile
|
||||
parsed, provider_used, provider_notice = self._interpret(
|
||||
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:
|
||||
parsed["ambiguities"] = []
|
||||
parsed["unresolved_spans"] = []
|
||||
@@ -2136,7 +2218,8 @@ class SeatDemoService:
|
||||
compile_started = time.perf_counter()
|
||||
try:
|
||||
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(
|
||||
function_config, context["devices"]
|
||||
)
|
||||
@@ -2199,7 +2282,11 @@ class SeatDemoService:
|
||||
"safety_check",
|
||||
"succeeded",
|
||||
(
|
||||
(
|
||||
"无线 MQTT CAN0 座椅语义白名单与 seat-ota 报文白名单已通过;"
|
||||
"本地 T2G 固件 profile、USB-CAN 与物理台架连接不参与校验。"
|
||||
if hardware_request
|
||||
and control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
else (
|
||||
"硬件启用、授权令牌、固件 profile 与目标座椅配置门禁已通过;"
|
||||
if self.hardware_settings.approval_required
|
||||
else "硬件启用、调试免令牌、固件 profile 与目标座椅配置门禁已通过;"
|
||||
@@ -2220,15 +2307,26 @@ class SeatDemoService:
|
||||
simulation_fault if simulated_hardware_request else None
|
||||
),
|
||||
"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": (
|
||||
"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
|
||||
else "draft"
|
||||
),
|
||||
"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"]
|
||||
)
|
||||
if vehicle_safety_preflight is not None:
|
||||
execution_report["spec"]["vehicle_safety_preflight"] = (
|
||||
vehicle_safety_preflight
|
||||
)
|
||||
for item in execution_report["spec"].get("results", []):
|
||||
item.setdefault("telemetry", {})[
|
||||
"vehicle_safety_preflight"
|
||||
@@ -4045,10 +4140,15 @@ class SeatDemoService:
|
||||
|
||||
compile_started = time.perf_counter()
|
||||
plans: dict[str, dict[str, Any]] = {}
|
||||
wireless_hardware_request = (
|
||||
hardware_request
|
||||
and control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
)
|
||||
for domain, config in configs.items():
|
||||
if domain == "seat":
|
||||
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(
|
||||
config,
|
||||
context["devices"],
|
||||
@@ -4225,13 +4325,10 @@ class SeatDemoService:
|
||||
"labels": {
|
||||
"simulation": "mixed" if hardware_request else "true",
|
||||
"seat_feedback": (
|
||||
"wireless_mqtt_ch1"
|
||||
if (
|
||||
hardware_request
|
||||
and control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
and native_config_keys
|
||||
and "seat" not in configs
|
||||
)
|
||||
"wireless_mqtt_ch0"
|
||||
if wireless_hardware_request and "seat" in configs
|
||||
else "wireless_mqtt_ch1"
|
||||
if wireless_hardware_request and native_config_keys
|
||||
else "physical_can_ch1"
|
||||
if hardware_request and native_config_keys and "seat" not in configs
|
||||
else "physical_can"
|
||||
@@ -4276,13 +4373,9 @@ class SeatDemoService:
|
||||
"execute",
|
||||
"succeeded" if execution_succeeded and not report_issues else "failed",
|
||||
(
|
||||
"无线 MQTT 白名单控制帧已提交;车辆实际状态以状态话题回读为准。"
|
||||
if (
|
||||
hardware_request
|
||||
and control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
and native_config_keys
|
||||
and execution_succeeded
|
||||
)
|
||||
"无线 MQTT CAN0/CAN1 白名单控制帧已提交;"
|
||||
"车辆实际状态以状态话题回读为准。"
|
||||
if wireless_hardware_request and execution_succeeded
|
||||
else "CH1 车身 CAN 动作已按目标字段物理回读闭环执行。"
|
||||
if hardware_request and native_config_keys and execution_succeeded
|
||||
else "副驾座椅已按独立 T2G 物理 CAN 回读闭环执行。"
|
||||
|
||||
@@ -23,6 +23,22 @@ FUZZY_UNMAPPED_CONTROL = re.compile(
|
||||
r"(?P<text>(?:座椅|靠背|腿托|空调|车窗|风口|灯光?)"
|
||||
r"(?:调|弄|搞)(?:得|到)?(?:舒服|合适|好)(?:一点|点|些)?)"
|
||||
)
|
||||
OUTLET_MODE_DISABLE = re.compile(
|
||||
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?"
|
||||
r"(?P<feature>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?|"
|
||||
r"(?P<feature_after>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?"
|
||||
r"(?:关闭|关掉|停止|取消)"
|
||||
)
|
||||
POSITIVE_OUTLET_MODE = re.compile(
|
||||
r"(?:打开|开启|启用|切换到|切换为|改为|设为|设置为|调整为|调为)"
|
||||
r"(?:空调)?(?:的)?(?:吹面|吹脸|吹脚|除霜)"
|
||||
)
|
||||
DRIVER_SEAT_THERMAL_MIDDLE = re.compile(
|
||||
r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}"
|
||||
r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
|
||||
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
|
||||
r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
|
||||
)
|
||||
|
||||
|
||||
def _span(utterance: str, match: re.Match[str]) -> dict[str, Any]:
|
||||
@@ -114,6 +130,21 @@ def _remove_seat_target(parsed: dict[str, Any], target: str) -> None:
|
||||
]
|
||||
|
||||
|
||||
def _remove_capability(parsed: dict[str, Any], capability: str) -> None:
|
||||
for key in (
|
||||
"actions",
|
||||
"domain_actions",
|
||||
"native_can_actions",
|
||||
"deferred_requests",
|
||||
"model_requests",
|
||||
):
|
||||
parsed[key] = [
|
||||
request
|
||||
for request in parsed.get(key, [])
|
||||
if request.get("capability") != capability
|
||||
]
|
||||
|
||||
|
||||
def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict[str, Any]:
|
||||
"""Preserve negation and unresolved control language without producing CAN."""
|
||||
|
||||
@@ -162,6 +193,100 @@ def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict
|
||||
]
|
||||
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
|
||||
|
||||
disabled_outlets = list(OUTLET_MODE_DISABLE.finditer(utterance))
|
||||
if disabled_outlets and POSITIVE_OUTLET_MODE.search(utterance) is None:
|
||||
_remove_capability(result, "climate.set_outlet_mode")
|
||||
for match in disabled_outlets:
|
||||
feature = match.group("feature") or match.group("feature_after")
|
||||
constraints.append(
|
||||
{
|
||||
"type": "prohibit_action",
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_outlet_mode",
|
||||
"parameters": {"disabled_feature": feature},
|
||||
"source_span": _span(utterance, match),
|
||||
}
|
||||
)
|
||||
unresolved.append(
|
||||
{
|
||||
"text": match.group(0),
|
||||
"reason": "outlet_feature_disable_requires_absolute_mode",
|
||||
}
|
||||
)
|
||||
result["ambiguities"] = [
|
||||
*result.get("ambiguities", []),
|
||||
{
|
||||
"field": "climate.outlet_mode",
|
||||
"question": (
|
||||
"当前CAN白名单只能设置完整出风模式;请明确改为吹脚、除霜等目标模式。"
|
||||
),
|
||||
"reason": "outlet_feature_disable_requires_absolute_mode",
|
||||
},
|
||||
]
|
||||
result["decision_summary"] = [
|
||||
*result.get("decision_summary", []),
|
||||
"检测到关闭单一出风方向的请求;未将其错误转换为开启该出风模式。",
|
||||
]
|
||||
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
|
||||
|
||||
thermal_middle = DRIVER_SEAT_THERMAL_MIDDLE.search(utterance)
|
||||
if thermal_middle is not None and not re.search(
|
||||
r"副驾|副驾驶|乘客|右前", utterance
|
||||
):
|
||||
feature = "通风" if "通风" in thermal_middle.group(0) else "加热"
|
||||
native_capability = (
|
||||
"seat.set_driver_ventilation_native"
|
||||
if feature == "通风"
|
||||
else "seat.set_driver_heating_native"
|
||||
)
|
||||
canonical_capability = (
|
||||
"seat.set_ventilation"
|
||||
if feature == "通风"
|
||||
else "seat.set_heating"
|
||||
)
|
||||
_remove_capability(result, native_capability)
|
||||
_remove_capability(result, canonical_capability)
|
||||
constraint = {
|
||||
"type": "unsupported_discrete_level",
|
||||
"domain": "seat",
|
||||
"capability": native_capability,
|
||||
"parameters": {"requested_level": "medium"},
|
||||
"source_span": _span(utterance, thermal_middle),
|
||||
}
|
||||
if not any(
|
||||
item.get("type") == constraint["type"]
|
||||
and item.get("capability") == native_capability
|
||||
and item.get("parameters") == constraint["parameters"]
|
||||
for item in constraints
|
||||
):
|
||||
constraints.append(constraint)
|
||||
unresolved.append(
|
||||
{
|
||||
"text": thermal_middle.group(0),
|
||||
"reason": "driver_seat_thermal_middle_level_unsupported",
|
||||
}
|
||||
)
|
||||
result["ambiguities"] = [
|
||||
*result.get("ambiguities", []),
|
||||
{
|
||||
"field": (
|
||||
"seat.driver.ventilation.level"
|
||||
if feature == "通风"
|
||||
else "seat.driver.heating.level"
|
||||
),
|
||||
"question": (
|
||||
f"主驾座椅{feature}实车白名单只有低档和高档,"
|
||||
"请明确选择低档或高档。"
|
||||
),
|
||||
"reason": "driver_seat_thermal_middle_level_unsupported",
|
||||
},
|
||||
]
|
||||
result["decision_summary"] = [
|
||||
*result.get("decision_summary", []),
|
||||
f"识别到主驾座椅{feature}中档请求;实车协议不支持中档,未降级为其他档位。",
|
||||
]
|
||||
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
|
||||
|
||||
if not result.get("actions") and not result.get("domain_actions"):
|
||||
for match in FUZZY_UNMAPPED_CONTROL.finditer(utterance):
|
||||
if not any(item.get("text") == match.group("text") for item in unresolved):
|
||||
|
||||
@@ -43,18 +43,24 @@ MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
|
||||
)
|
||||
MQTT_SAFETY_FEEDBACK_IDS = frozenset({0x342, 0x4EB})
|
||||
MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset({0x2F5, 0x37B})
|
||||
# Frames accepted from the Android head unit are the seat-ota wire protocol.
|
||||
# ``0x302`` is an internal compatibility frame synthesized below so the
|
||||
# existing seat-domain state reader can consume the four firmware axes; it is
|
||||
# deliberately not accepted as authoritative MQTT input.
|
||||
MQTT_SEAT_WIRE_FEEDBACK_IDS = frozenset({0x6A0, 0x6A2})
|
||||
MQTT_SEAT_FEEDBACK_IDS = frozenset({0x302, 0x6A2})
|
||||
# CAN0 accepts both state formats currently emitted by the passenger-seat
|
||||
# stack. The production firmware publishes its authoritative classic-CAN
|
||||
# status directly on 0x301..0x306/0x4FA. Older head-unit builds expose the
|
||||
# read-only seat-ota request/response exchange on 0x6A0/0x6A2; that exchange is
|
||||
# retained and translated to an internal 0x302 position frame below.
|
||||
MQTT_SEAT_NATIVE_FEEDBACK_IDS = frozenset(
|
||||
{0x301, 0x302, 0x303, 0x304, 0x305, 0x306, 0x4FA}
|
||||
)
|
||||
MQTT_SEAT_WIRE_FEEDBACK_IDS = MQTT_SEAT_NATIVE_FEEDBACK_IDS | frozenset(
|
||||
{0x6A0, 0x6A2}
|
||||
)
|
||||
MQTT_SEAT_FEEDBACK_IDS = MQTT_SEAT_NATIVE_FEEDBACK_IDS | frozenset({0x6A2})
|
||||
MQTT_ACCEPTED_FEEDBACK_IDS = {
|
||||
0: MQTT_SEAT_FEEDBACK_IDS,
|
||||
1: MQTT_VEHICLE_FEEDBACK_IDS,
|
||||
}
|
||||
MQTT_APPROVED_CONTROL_IDS = {
|
||||
0: frozenset({0x6A0}),
|
||||
0: frozenset({0x29E, 0x3CE, 0x6A0}),
|
||||
1: frozenset({0x1DE, 0x32B, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}),
|
||||
}
|
||||
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:
|
||||
"""One process-wide MQTT connection serving snapshots and executor buses."""
|
||||
|
||||
@@ -633,7 +672,7 @@ class MqttCanTransport:
|
||||
discriminator = (
|
||||
frame.data[0]
|
||||
if frame.channel == 0
|
||||
and frame.arbitration_id == 0x302
|
||||
and frame.arbitration_id in {0x302, 0x303, 0x304}
|
||||
and frame.data
|
||||
else 0
|
||||
)
|
||||
@@ -751,7 +790,15 @@ class MqttCanTransport:
|
||||
initial_frames = tuple(
|
||||
frame
|
||||
for (seen_channel, _frame_id, _discriminator),
|
||||
(captured_monotonic, frame) in sorted(self._latest_bus_frames.items())
|
||||
(captured_monotonic, frame) in sorted(
|
||||
self._latest_bus_frames.items(),
|
||||
key=lambda item: (
|
||||
0 if item[0][1] == 0x306 else
|
||||
1 if item[0][1] == 0x4FA else
|
||||
2,
|
||||
item[0],
|
||||
),
|
||||
)
|
||||
if replay_latest
|
||||
and seen_channel == channel
|
||||
and now - captured_monotonic <= self.freshness_s
|
||||
@@ -772,8 +819,10 @@ class MqttCanTransport:
|
||||
if frame.channel == 0 and (frame.is_fd or frame.bitrate_switch):
|
||||
raise CanGatewayError("CAN0 passenger-seat control requires classic CAN")
|
||||
_validate_headunit_command_frame(frame)
|
||||
if frame.channel == 0:
|
||||
if frame.channel == 0 and frame.arbitration_id == 0x6A0:
|
||||
_validate_ota_seat_runtime_control(frame.data)
|
||||
elif frame.channel == 0:
|
||||
_validate_native_seat_thermal_control(frame)
|
||||
with self._condition:
|
||||
client = self._client
|
||||
state = self._state
|
||||
|
||||
@@ -367,7 +367,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
path,
|
||||
)
|
||||
if (
|
||||
path not in {"/api/requests", "/api/requests/preview"}
|
||||
path not in {
|
||||
"/api/requests",
|
||||
"/api/requests/preview",
|
||||
"/api/can/commands",
|
||||
}
|
||||
and not supplement_match
|
||||
):
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
@@ -402,7 +406,17 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
if self.auth.config.enabled:
|
||||
self.auth.api_requests.record(username)
|
||||
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"))
|
||||
if utterance:
|
||||
self.vehicle_display_sync.update_task(
|
||||
@@ -451,7 +465,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
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(
|
||||
path,
|
||||
supplement_match,
|
||||
@@ -460,7 +477,33 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
source=source,
|
||||
)
|
||||
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)
|
||||
elif supplement_match:
|
||||
result = self.service.supplement_native_can(
|
||||
|
||||
+26606
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -410,7 +410,7 @@
|
||||
],
|
||||
"rejection_cases": [
|
||||
{"id": "SAFE-DRIVER-THERMAL-LEVEL", "utterance": "主驾座椅通风4档", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-DRIVER-MASSAGE-GENERIC", "utterance": "打开主驾座椅按摩", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-DRIVER-THERMAL-MIDDLE", "utterance": "主驾座椅通风中档", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-DRIVER-MOTION", "utterance": "把驾驶位靠背调到50%", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-DRIVER-MODE", "utterance": "主驾进入零重力模式", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-FRONT-ROW", "utterance": "打开前排座椅加热", "expected_status": "needs_confirmation"},
|
||||
|
||||
@@ -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()
|
||||
@@ -329,6 +329,188 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
|
||||
24,
|
||||
)
|
||||
|
||||
def test_disabling_one_outlet_feature_never_enables_that_mode(self) -> None:
|
||||
for index, utterance in enumerate(
|
||||
("关闭吹面出风口", "把吹面出风口关闭")
|
||||
):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-one-close-outlet-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "needs_confirmation")
|
||||
self.assertEqual(self._debug_commands(result), [])
|
||||
ambiguities = result["intent_ir"]["spec"]["ambiguities"]
|
||||
self.assertEqual(
|
||||
ambiguities[0]["reason"],
|
||||
"outlet_feature_disable_requires_absolute_mode",
|
||||
)
|
||||
|
||||
preview = self.service.preview_native_can(
|
||||
"关闭吹面出风口",
|
||||
provider="openai",
|
||||
conversation_id="conv-can-one-close-outlet-preview",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertFalse(preview["supplement_required"])
|
||||
self.assertEqual(preview["unresolved_clauses"], [])
|
||||
self.assertEqual(self._debug_commands(preview), [])
|
||||
|
||||
explicit_replacement = self.service.process(
|
||||
"关闭吹面出风口,切换到吹脚模式",
|
||||
provider="local",
|
||||
conversation_id="conv-can-one-replace-outlet",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(explicit_replacement["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(explicit_replacement), ["ac mode feet"])
|
||||
|
||||
def test_driver_seat_natural_language_levels_and_motion_aliases(self) -> None:
|
||||
cases = (
|
||||
("打开主驾座椅按摩", ["seat driver massage mode relax"]),
|
||||
(
|
||||
"主驾座椅按摩强度设置为最大",
|
||||
["seat driver massage strength strong"],
|
||||
),
|
||||
(
|
||||
"主驾座椅按摩强度最小",
|
||||
["seat driver massage strength weak"],
|
||||
),
|
||||
(
|
||||
"主驾座椅按摩强度调中",
|
||||
["seat driver massage strength medium"],
|
||||
),
|
||||
("主驾座椅朝前挪一秒", ["seat driver horizontal forward 1000"]),
|
||||
("主驾座椅后退两秒", ["seat driver horizontal backward 2000"]),
|
||||
("主驾座椅椅背放倒", ["seat driver backrest backward 500"]),
|
||||
("主驾座椅座垫放低", ["seat driver basin down 500"]),
|
||||
("主驾座椅往上移动", ["seat driver height up 500"]),
|
||||
("主驾座椅通风最低", ["seat driver vent low"]),
|
||||
("主驾座椅通风最小", ["seat driver vent low"]),
|
||||
("主驾座椅通风最高", ["seat driver vent high"]),
|
||||
("主驾座椅通风最大", ["seat driver vent high"]),
|
||||
("空调风量设置为最低", ["ac fan 1"]),
|
||||
("空调风量设置为中等", ["ac fan 4"]),
|
||||
("空调风量设置为最大", ["ac fan 7"]),
|
||||
("氛围灯亮度最低", ["light ambient brightness 1"]),
|
||||
("氛围灯亮度中等", ["light ambient brightness 3"]),
|
||||
("氛围灯亮度最高", ["light ambient brightness 5"]),
|
||||
(
|
||||
"打开主驾座椅通风并关闭按摩",
|
||||
["seat driver vent low", "seat driver massage off"],
|
||||
),
|
||||
(
|
||||
"关闭主驾座椅通风并打开按摩",
|
||||
["seat driver vent off", "seat driver massage mode relax"],
|
||||
),
|
||||
)
|
||||
for index, (utterance, expected) in enumerate(cases):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-one-seat-alias-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(result), expected)
|
||||
|
||||
stop_all = self.service.process(
|
||||
"停止主驾座椅移动",
|
||||
provider="local",
|
||||
conversation_id="conv-can-one-seat-stop-all",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(stop_all["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
self._debug_commands(stop_all),
|
||||
[
|
||||
"seat driver horizontal stop",
|
||||
"seat driver height stop",
|
||||
"seat driver backrest stop",
|
||||
"seat driver basin stop",
|
||||
],
|
||||
)
|
||||
|
||||
def test_unsupported_driver_seat_middle_thermal_level_is_explicit(self) -> None:
|
||||
for index, feature in enumerate(("通风", "加热")):
|
||||
utterance = f"主驾座椅{feature}调到中档"
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-one-seat-middle-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "needs_confirmation")
|
||||
self.assertEqual(self._debug_commands(result), [])
|
||||
self.assertIn(
|
||||
"driver_seat_thermal_middle_level_unsupported",
|
||||
{
|
||||
item.get("reason")
|
||||
for item in result["intent_ir"]["spec"]["ambiguities"]
|
||||
},
|
||||
)
|
||||
|
||||
preview = self.service.preview_native_can(
|
||||
"主驾座椅通风设置为中等",
|
||||
provider="openai",
|
||||
conversation_id="conv-can-one-seat-middle-preview",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertFalse(preview["supplement_required"])
|
||||
self.assertEqual(preview["unresolved_clauses"], [])
|
||||
self.assertEqual(self._debug_commands(preview), [])
|
||||
|
||||
def test_vehicle_position_aliases_stay_scoped_to_the_device_domain(self) -> None:
|
||||
cases = (
|
||||
("打开左前车窗", ["window front-left open"]),
|
||||
("打开右前车窗", ["window front-right open"]),
|
||||
("打开左后车窗", ["window rear-left open"]),
|
||||
("打开右后车窗", ["window rear-right open"]),
|
||||
("打开主驾车窗", ["window front-left open"]),
|
||||
("打开副驾车窗", ["window front-right open"]),
|
||||
("打开左前座椅通风", ["seat driver vent low"]),
|
||||
("打开主驾座椅通风", ["seat driver vent low"]),
|
||||
("主驾空调温度设置为24度", ["ac temp driver 24"]),
|
||||
("副驾空调温度设置为24度", ["ac temp passenger 24"]),
|
||||
)
|
||||
for index, (utterance, expected) in enumerate(cases):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-position-alias-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(result), expected)
|
||||
|
||||
for index, utterance in enumerate(
|
||||
("打开右前座椅通风", "打开副驾座椅通风")
|
||||
):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-passenger-alias-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
operations = [
|
||||
operation
|
||||
for config in result.get("function_configs", {}).values()
|
||||
for operation in config.get("spec", {}).get("operations", [])
|
||||
]
|
||||
self.assertNotIn(
|
||||
"seat.set_driver_ventilation_native",
|
||||
{item["capability"] for item in operations},
|
||||
)
|
||||
self.assertEqual(operations[0]["capability"], "seat.set_ventilation")
|
||||
self.assertEqual(operations[0]["target"]["zone"], "front_right")
|
||||
|
||||
def test_fractional_values_are_not_silently_truncated(self) -> None:
|
||||
utterances = (
|
||||
"空调温度设为24.5度",
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.direct_can_control import build_direct_can_intent
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
def test_driver_device_batch_keeps_each_exact_control_independent() -> None:
|
||||
commands = [
|
||||
"seat driver vent low",
|
||||
"seat driver heat off",
|
||||
"seat driver massage mode stretch",
|
||||
"seat driver massage strength strong",
|
||||
]
|
||||
|
||||
normalized, parsed = build_direct_can_intent(commands)
|
||||
|
||||
assert normalized == commands
|
||||
assert [item["capability"] for item in parsed["native_can_actions"]] == [
|
||||
"seat.set_driver_ventilation_native",
|
||||
"seat.set_driver_heating_native",
|
||||
"seat.set_driver_massage_native",
|
||||
"seat.set_driver_massage_native",
|
||||
]
|
||||
assert [item["parameters"] for item in parsed["native_can_actions"]] == [
|
||||
{"state": "on", "level": 2},
|
||||
{"state": "off"},
|
||||
{"action": "mode", "mode": "stretch"},
|
||||
{"action": "strength", "strength": 3},
|
||||
]
|
||||
assert {
|
||||
item["parse_source"] for item in parsed["native_can_actions"]
|
||||
} == {"direct_control"}
|
||||
|
||||
|
||||
def test_passenger_device_batch_builds_can0_position_action() -> None:
|
||||
normalized, parsed = build_direct_can_intent(
|
||||
["seat passenger backrest position 36"]
|
||||
)
|
||||
|
||||
assert normalized == ["seat passenger backrest position 36"]
|
||||
assert parsed["native_can_actions"] == []
|
||||
assert parsed["actions"] == [
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": "backrest_recline",
|
||||
"position_pct": 36,
|
||||
"parse_source": "direct_control",
|
||||
"source_span": "seat passenger backrest position 36",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_passenger_can0_canonical_absolute_relative_thermal_and_dynamic() -> None:
|
||||
context = {
|
||||
"devices": {
|
||||
"passenger": {
|
||||
"position": {
|
||||
"longitudinal": {"position_pct": 40},
|
||||
"cushion_tilt": {"position_pct": 50},
|
||||
"backrest_recline": {"position_pct": 60},
|
||||
"legrest_height": {"position_pct": 70},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
commands = [
|
||||
"seat passenger horizontal position 35",
|
||||
"seat passenger basin relative up 10",
|
||||
"seat passenger vent medium",
|
||||
"seat passenger dynamic rocking-horse",
|
||||
"seat passenger dynamic stop",
|
||||
]
|
||||
|
||||
normalized, parsed = build_direct_can_intent(commands, context)
|
||||
|
||||
assert normalized == commands
|
||||
assert parsed["detected_domains"] == ["seat"]
|
||||
assert parsed["actions"][0]["axis"] == "longitudinal"
|
||||
assert parsed["actions"][0]["position_pct"] == 35
|
||||
assert parsed["actions"][1]["axis"] == "cushion_tilt"
|
||||
assert parsed["actions"][1]["position_pct"] == 40
|
||||
assert parsed["actions"][1]["motion_intent"] == {
|
||||
"mode": "relative",
|
||||
"direction": "decrease",
|
||||
"start_position_pct": 50,
|
||||
"delta_position_pct": 10,
|
||||
}
|
||||
assert parsed["actions"][2]["function"] == "ventilation"
|
||||
assert parsed["actions"][2]["requested_level"] == 2
|
||||
assert parsed["actions"][3]["action_type"] == "dynamic_mode"
|
||||
assert parsed["actions"][3]["mode_id"] == 1
|
||||
assert parsed["actions"][4]["action_type"] == "dynamic_mode_stop"
|
||||
|
||||
|
||||
def test_passenger_can0_relative_command_requires_current_position() -> None:
|
||||
with pytest.raises(ValueError, match="requires a valid current"):
|
||||
build_direct_can_intent(
|
||||
["seat passenger horizontal relative forward 10"],
|
||||
{"devices": {"passenger": {"position": {}}}},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"commands",
|
||||
(
|
||||
[],
|
||||
["seat driver vent medium"],
|
||||
["seat passenger height position 101"],
|
||||
["headlight low-beam on"],
|
||||
),
|
||||
)
|
||||
def test_direct_batch_rejects_non_whitelisted_commands(commands: list[str]) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
build_direct_can_intent(commands)
|
||||
|
||||
|
||||
def test_service_direct_batch_bypasses_natural_language_interpreter() -> None:
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = Path(directory) / "artifacts"
|
||||
with patch.object(
|
||||
service,
|
||||
"_interpret",
|
||||
side_effect=AssertionError("natural-language parsing must not run"),
|
||||
):
|
||||
result = service.process_can_commands(
|
||||
[
|
||||
"ac on",
|
||||
"ac temp driver 24",
|
||||
"ac fan 2",
|
||||
"ac cycle internal",
|
||||
],
|
||||
execution_mode="dry_run",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["provider_used"] == "direct_can_control"
|
||||
assert result["control_transport"] == "wireless_mqtt"
|
||||
assert result["can_commands"] == [
|
||||
"ac on",
|
||||
"ac temp driver 24",
|
||||
"ac fan 2",
|
||||
"ac cycle internal",
|
||||
]
|
||||
operations = [
|
||||
operation
|
||||
for configuration in result["function_configs"].values()
|
||||
for operation in configuration["spec"]["operations"]
|
||||
]
|
||||
assert [item["capability"] for item in operations] == [
|
||||
"climate.set_power",
|
||||
"climate.set_temperature",
|
||||
"climate.set_fan_level",
|
||||
"climate.set_cycle_mode",
|
||||
]
|
||||
assert {item["parse_source"] for item in operations} == {"direct_control"}
|
||||
|
||||
|
||||
def test_service_direct_batch_combines_can0_and_can1_semantics() -> None:
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = Path(directory) / "artifacts"
|
||||
result = service.process_can_commands(
|
||||
[
|
||||
"seat passenger basin relative up 10",
|
||||
"seat passenger vent medium",
|
||||
"seat passenger dynamic rocking-horse",
|
||||
"ac fan 2",
|
||||
"light ambient on",
|
||||
],
|
||||
conversation_id="conv-direct-can0-can1",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["can_commands"] == [
|
||||
"seat passenger basin relative up 10",
|
||||
"seat passenger vent medium",
|
||||
"seat passenger dynamic rocking-horse",
|
||||
"ac fan 2",
|
||||
"light ambient on",
|
||||
]
|
||||
operations = [
|
||||
operation
|
||||
for configuration in result["function_configs"].values()
|
||||
for operation in configuration["spec"]["operations"]
|
||||
]
|
||||
by_capability = {item["capability"]: item for item in operations}
|
||||
assert by_capability["seat.set_cushion_tilt"]["parameters"] == {
|
||||
"position_pct": 40
|
||||
}
|
||||
assert by_capability["seat.set_cushion_tilt"]["motion"] == {
|
||||
"mode": "relative",
|
||||
"direction": "decrease",
|
||||
"start_position_pct": 50,
|
||||
"delta_position_pct": 10,
|
||||
}
|
||||
assert by_capability["seat.start_dynamic_mode"]["parameters"] == {
|
||||
"mode_id": 1
|
||||
}
|
||||
assert by_capability["seat.set_ventilation"]["parameters"] == {
|
||||
"state": "on",
|
||||
"level": 2,
|
||||
}
|
||||
assert by_capability["climate.set_fan_level"]["parameters"] == {"level": 2}
|
||||
assert by_capability["ambient_light.set_power"]["parameters"] == {
|
||||
"state": "on"
|
||||
}
|
||||
assert {item["parse_source"] for item in operations} == {"direct_control"}
|
||||
@@ -190,6 +190,10 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "mode", "mode": "wave"},
|
||||
),
|
||||
"打开主驾座椅按摩": (
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "mode", "mode": "relax"},
|
||||
),
|
||||
"主驾座椅按摩强度调高": (
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "strength", "strength": 3},
|
||||
@@ -239,7 +243,6 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
"打开右前座椅加热",
|
||||
"打开主驾和副驾座椅通风",
|
||||
"seat driver vent low, seat passenger vent low",
|
||||
"打开主驾座椅按摩",
|
||||
"主驾座椅向前移动50毫秒",
|
||||
"seat driver height forward 500",
|
||||
)
|
||||
|
||||
@@ -306,7 +306,7 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
||||
self.assertIn("const driverOnlyCh1 = isDriverOnlyCh1Runtime()", submit_source)
|
||||
self.assertIn("&& !driverOnlyCh1", submit_source)
|
||||
self.assertIn(
|
||||
'state.executionMode === "hardware" && !driverOnlyCh1',
|
||||
'&& state.controlTransport !== "wireless_mqtt"',
|
||||
submit_source,
|
||||
)
|
||||
startup_source = self.app_source[
|
||||
|
||||
@@ -203,7 +203,7 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
self.assertEqual(frame.arbitration_id, 0x42E)
|
||||
self.assertTrue(frame.is_fd)
|
||||
|
||||
def test_ignores_legacy_can0_state_outside_seat_ota_wire_protocol(self) -> None:
|
||||
def test_accepts_native_passenger_seat_firmware_status(self) -> None:
|
||||
frames = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -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:
|
||||
values = {
|
||||
@@ -317,7 +319,7 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
[(frame.channel, frame.arbitration_id) for frame in frames],
|
||||
[(1, 0x342)],
|
||||
[(0, 0x302), (1, 0x342)],
|
||||
)
|
||||
|
||||
def test_rejects_decoded_state_invalid_channels_and_channel_conflicts(self) -> None:
|
||||
@@ -435,6 +437,76 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
self.assertLess(time.perf_counter() - started, 0.05)
|
||||
self.assertEqual(snapshot.observed_ids, frozenset({0x388}))
|
||||
|
||||
def test_native_can0_status_maps_to_passenger_frontend_state(self) -> None:
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"signals": [
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x302",
|
||||
"frameType": "classic",
|
||||
"bytes": [axis, 0x26, 0x08, 0, 0, value & 0xFF,
|
||||
(value >> 8) & 0xFF, 0],
|
||||
}
|
||||
for axis, value in enumerate((1000, 2500, 5000, 9000))
|
||||
] + [
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x4FA",
|
||||
"frameType": "classic",
|
||||
"bytes": [0x22, 0, 0, 0, 0, 0, 0, 0],
|
||||
},
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x306",
|
||||
"frameType": "classic",
|
||||
"bytes": [1, 2, 3, 0x0F, 2, 0, 100, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(state_configured=True)
|
||||
|
||||
payload = service._synchronize_hardware_state_once(
|
||||
"conv-native-can0-state",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
passenger = payload["actuator_state"]["passenger"]
|
||||
self.assertEqual(
|
||||
{
|
||||
axis: value["position_pct"]
|
||||
for axis, value in passenger["position"].items()
|
||||
if axis in {
|
||||
"longitudinal",
|
||||
"cushion_tilt",
|
||||
"backrest_recline",
|
||||
"legrest_height",
|
||||
}
|
||||
},
|
||||
{
|
||||
"longitudinal": 10,
|
||||
"cushion_tilt": 25,
|
||||
"backrest_recline": 50,
|
||||
"legrest_height": 90,
|
||||
},
|
||||
)
|
||||
self.assertEqual(passenger["ventilation"]["level"], 1)
|
||||
self.assertEqual(passenger["heating"]["level"], 3)
|
||||
self.assertEqual(passenger["dynamic_mode"]["mode_id"], 1)
|
||||
self.assertEqual(passenger["dynamic_mode"]["state"], "running")
|
||||
self.assertTrue(
|
||||
all(
|
||||
item["feedback_source"] == "wireless_mqtt_ch0"
|
||||
for item in passenger["position"].values()
|
||||
if item.get("position_valid") is not None
|
||||
)
|
||||
)
|
||||
|
||||
def test_vehicle_envelope_four_frames_decode_from_raw_can1_bytes(self) -> None:
|
||||
self.client.emit(
|
||||
"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(
|
||||
self,
|
||||
) -> None:
|
||||
@@ -935,6 +1159,21 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
self.assertEqual(payload["command"], "")
|
||||
self.assertEqual(payload["frameType"], "classic")
|
||||
self.assertFalse(payload["brs"])
|
||||
bus.set_debug_command("seat passenger heat low")
|
||||
bus.send(0x3CE, bytes.fromhex("10 00 00 00 00 00 00 00"))
|
||||
bus.send(0x29E, bytes.fromhex("16 00 10 00 00 00 00 00"))
|
||||
thermal_payloads = [
|
||||
json.loads(item[1]) for item in self.client.published[-2:]
|
||||
]
|
||||
self.assertEqual(
|
||||
[item["canId"] for item in thermal_payloads],
|
||||
["0x3CE", "0x29E"],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(item["command"] == "seat passenger heat low" for item in thermal_payloads)
|
||||
)
|
||||
with self.assertRaisesRegex(Exception, "0x29E thermal control payload"):
|
||||
bus.send(0x29E, bytes(8))
|
||||
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
|
||||
bus.send(0x431, bytes(8))
|
||||
with self.assertRaisesRegex(Exception, "requires classic CAN"):
|
||||
|
||||
@@ -2,8 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
from cognitive_agent.application.mqtt_ota_seat_executor import (
|
||||
MqttOtaSeatExecutor,
|
||||
)
|
||||
@@ -11,6 +15,9 @@ from cognitive_agent.infrastructure.can.gateway import CanFrame
|
||||
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:
|
||||
flags = (1 << 3) if valid else 0
|
||||
return CanFrame(
|
||||
@@ -57,6 +64,8 @@ class _FakeOtaBus:
|
||||
bitrate_switch=bitrate_switch,
|
||||
)
|
||||
self.sent.append(frame)
|
||||
if arbitration_id != 0x6A0:
|
||||
return frame
|
||||
opcode = data[2]
|
||||
job_state = (
|
||||
1 | (4 << 4) | (9 << 8)
|
||||
@@ -87,6 +96,30 @@ class _FakeOtaBus:
|
||||
|
||||
|
||||
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]:
|
||||
return {"position": {}, "complete": False}
|
||||
|
||||
@@ -97,6 +130,37 @@ class _StatusExecutor:
|
||||
) -> dict[str, object]:
|
||||
return {"position": {}, "complete": True, "missing": []}
|
||||
|
||||
def _execute_thermal(
|
||||
self,
|
||||
bus: _FakeOtaBus,
|
||||
command: dict[str, object],
|
||||
operations: list[dict[str, object]],
|
||||
) -> list[dict[str, object]]:
|
||||
encoded = self.matrix.encode(
|
||||
str(command["message"]),
|
||||
command["signals"], # type: ignore[arg-type]
|
||||
)
|
||||
sent = bus.send(
|
||||
int(encoded["frame_id"], 16),
|
||||
bytes.fromhex(encoded["data_hex"]),
|
||||
)
|
||||
feedback = bus.receive(100)
|
||||
if feedback is None or feedback.arbitration_id != 0x4FA:
|
||||
raise RuntimeError("missing thermal feedback")
|
||||
return [
|
||||
{
|
||||
"ref": str(operation["id"]),
|
||||
"domain": "seat",
|
||||
"status": "succeeded",
|
||||
"message": "thermal feedback confirmed",
|
||||
"telemetry": {
|
||||
"command_frame": sent.public(),
|
||||
"feedback_frame": feedback.public(),
|
||||
},
|
||||
}
|
||||
for operation in operations
|
||||
]
|
||||
|
||||
|
||||
def _function_config(capability: str, parameters: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
@@ -115,6 +179,65 @@ def _function_config(capability: str, parameters: dict[str, object]) -> dict[str
|
||||
|
||||
|
||||
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:
|
||||
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(
|
||||
bus.debug_commands,
|
||||
["seat passenger longitudinal position 50"],
|
||||
["seat passenger horizontal position 50"],
|
||||
)
|
||||
self.assertEqual(
|
||||
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(0x62, [frame.data[2] for frame in bus.sent])
|
||||
|
||||
def test_wireless_thermal_command_fails_before_publish(self) -> None:
|
||||
bus = _FakeOtaBus([])
|
||||
def test_wireless_thermal_command_uses_native_firmware_frames(self) -> None:
|
||||
bus = _FakeOtaBus(
|
||||
[CanFrame(0x4FA, bytes((0x11, 0, 0, 0, 0, 0, 0, 0)), channel=0)]
|
||||
)
|
||||
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
|
||||
_StatusExecutor(),
|
||||
lambda: bus,
|
||||
@@ -212,10 +337,21 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
plan = {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "operation-1",
|
||||
"message": "T2G_Seat_Thermal_Mode_3CE",
|
||||
"signals": {"Driver_Seat_Thermal_Mode": 1},
|
||||
},
|
||||
{
|
||||
"operation_ref": "operation-1",
|
||||
"message": "T2G_Seat_Thermal_Control_29E",
|
||||
"signals": {},
|
||||
"signals": {
|
||||
"Driver_Seat_Ventilation_On": 2,
|
||||
"Driver_Seat_Ventilation_Mode": 1,
|
||||
"Driver_Seat_Heating_On": 1,
|
||||
"Driver_Seat_Ventilation_Gear": 0,
|
||||
"Driver_Seat_Heating_Gear": 1,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -226,9 +362,14 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
_function_config("seat.set_heating", {"state": "on", "level": 1}),
|
||||
)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
self.assertIn("does not provide wireless thermal control", report["spec"]["results"][0]["message"])
|
||||
self.assertEqual(bus.sent, [])
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual([frame.arbitration_id for frame in bus.sent], [0x3CE, 0x29E])
|
||||
self.assertEqual(bus.sent[0].data, bytes.fromhex("10 00 00 00 00 00 00 00"))
|
||||
self.assertEqual(bus.sent[1].data, bytes.fromhex("16 00 10 00 00 00 00 00"))
|
||||
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:
|
||||
for semantic_mode, firmware_selector, mode_name in (
|
||||
@@ -267,7 +408,7 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
bus.debug_commands,
|
||||
[
|
||||
"seat passenger dynamic start "
|
||||
"seat passenger dynamic "
|
||||
+ ("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-apply="${device.id}"' in console
|
||||
assert 'new CustomEvent("cognitive-agent:vehicle-control-request"' in console
|
||||
assert "window.CognitiveAgentConsole?.executeUtterance?.(utterance" in console
|
||||
assert 'new CustomEvent("cognitive-agent:vehicle-can-control-request"' in console
|
||||
assert "window.CognitiveAgentConsole?.executeCanCommands" in console
|
||||
assert "device.commands(values).filter(Boolean)" in console
|
||||
assert 'controllable: false' in console
|
||||
assert '.v2-device-control-row input[type="range"]' in styles
|
||||
assert ".v2-device-control-select" in styles
|
||||
assert ".v2-device-control-apply" in styles
|
||||
@@ -229,7 +231,7 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
|
||||
"light ambient brightness ${level}",
|
||||
"shade position ${position}",
|
||||
"seat passenger ${axis[1]} position ${position}",
|
||||
"seat passenger dynamic start ${dynamic.name}",
|
||||
"seat passenger dynamic ${dynamic.name}",
|
||||
"seat passenger dynamic stop",
|
||||
"otaCan0Frame(0x64, axis[2], targetPermille)",
|
||||
"otaCan0Frame(0x66, dynamic.selector, 300)",
|
||||
|
||||
@@ -185,8 +185,9 @@ def test_frontend_two_stage_contract_and_source_badges() -> None:
|
||||
assert '"cognitive-agent:validated-can-commands"' in app
|
||||
assert "本地规则命令已执行;云端补充失败" in app
|
||||
assert 'class="v2-can-command-source"' in console
|
||||
assert 'item.parseSource === "direct_control" ? "设备控件"' in console
|
||||
assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console
|
||||
assert '"cognitive-agent:validated-can-commands"' in console
|
||||
assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles
|
||||
assert 'path not in {"/api/requests", "/api/requests/preview"}' in server
|
||||
assert '"/api/can/commands"' in server
|
||||
assert '"type": "validated_commands"' in server
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user