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