This commit is contained in:
orin
2026-07-30 14:09:04 +08:00
parent 25b24ac4cd
commit 49f5c49c53
35 changed files with 4078 additions and 48 deletions
+2
View File
@@ -2,6 +2,8 @@
面向车载执行器的认知编排 Agent 单仓工程。系统把自然语言请求转换为可校验的语义配置,再由确定性的领域插件编译为 CAN 命令计划,最后经安全策略和执行器下发。
启动前端:PYTHONPATH=src:domains/seat/src .omx/python311-venv/bin/python -m cognitive_agent.server
## 建设阶段
- 阶段一:每个领域独立完成能力定义、功能配置生成、配置校验、CAN 编译和单执行器测试。
+151 -6
View File
@@ -14,6 +14,7 @@ const state = {
provider: "openai",
executionMode: "dry_run",
conversationId: loadConversationId(),
vehicleId: null,
runtime: null,
result: null,
progressTimer: null,
@@ -37,8 +38,8 @@ const TWIN_DEFAULT_POSITIONS = {
legrest_extension: 0,
};
const TWIN_ANGLE_RANGES = {
backrest_recline: [95, 140],
legrest_height: [95, 160],
backrest_recline: [95, 150],
legrest_height: [95, 163],
};
function initJointTwinLayout() {
@@ -2436,11 +2437,132 @@ function selectExecutionMode(mode) {
refreshIcons();
}
function vehicleProfiles() {
return Array.isArray(state.runtime?.vehicles) ? state.runtime.vehicles : [];
}
function findVehicleProfile(vehicleId) {
return vehicleProfiles().find((vehicle) => vehicle.id === vehicleId)
|| state.runtime?.vehicle
|| null;
}
function applyTwinAngleRanges(vehicle) {
Object.keys(TWIN_ANGLE_RANGES).forEach((axis) => delete TWIN_ANGLE_RANGES[axis]);
const motors = vehicle?.seat?.motors || {};
Object.entries(motors).forEach(([axis, motor]) => {
const display = motor?.display_angle;
if (!display) return;
const zero = Number(display.zero_deg);
const maximum = Number(display.max_deg);
if (Number.isFinite(zero) && Number.isFinite(maximum) && maximum > zero) {
TWIN_ANGLE_RANGES[axis] = [zero, maximum];
}
});
}
function selectVehicle(vehicleId, { silent = false } = {}) {
const vehicle = findVehicleProfile(vehicleId);
if (!vehicle) return;
state.vehicleId = vehicle.id;
const select = $("#vehicleSelect");
if (select) select.value = vehicle.id;
applyTwinAngleRanges(vehicle);
const longitudinal = vehicle.seat?.motors?.longitudinal;
const backrest = vehicle.seat?.motors?.backrest_recline;
$("#vehicleNote").textContent = longitudinal && backrest
? `${vehicle.name} · 水平 ${longitudinal.range}${longitudinal.unit} · 靠背 ${backrest.range}${backrest.unit}`
: `${vehicle.name} · 已加载车型配置`;
if (state.result?.actuator_state) {
renderSeatState(state.result.actuator_state);
} else if (state.seatTwin?.actuatorState) {
updateSeatTwin(state.seatTwin.actuatorState);
}
if (!silent) showToast(`已切换车型:${vehicle.name}`);
}
function populateVehicleSelector() {
const select = $("#vehicleSelect");
if (!select) return;
const vehicles = vehicleProfiles();
select.innerHTML = vehicles.length
? vehicles.map((vehicle) => `<option value="${vehicle.id}">${vehicle.name}</option>`).join("")
: '<option value="">未找到车型配置</option>';
const defaultVehicleId = state.runtime?.default_vehicle_id
|| state.runtime?.vehicle?.id
|| vehicles[0]?.id
|| "";
select.disabled = vehicles.length === 0;
if (defaultVehicleId) selectVehicle(defaultVehicleId, { silent: true });
}
function modeProfiles() {
return Array.isArray(state.runtime?.modes) ? state.runtime.modes : [];
}
function modeChipLabel(mode) {
const name = String(mode?.name || mode?.id || "模式");
return name.endsWith("模式") && name.length > 2
? name.slice(0, -2)
: name;
}
function populateModeChips() {
const group = $(".mode-chips");
if (!group) return;
const buttons = () => $$("button", group);
modeProfiles().forEach((mode) => {
const label = modeChipLabel(mode);
let button = buttons().find((item) => item.dataset.modeProfileId === mode.id)
|| buttons().find((item) => item.textContent.trim() === label);
if (!button) {
button = document.createElement("button");
button.type = "button";
const icon = document.createElement("i");
icon.dataset.lucide = mode.icon || "bookmark";
button.append(icon, document.createTextNode(label));
group.append(button);
}
button.dataset.modeProfileId = mode.id;
button.dataset.modeCommand = `进入${mode.name}`;
button.title = mode.description || mode.name;
});
refreshIcons();
}
function applyModeCommand(command) {
if (!command) return;
$("#utterance").value = command;
$("#requestForm").requestSubmit();
}
function toggleSaveModeComposer() {
const composer = $("#saveModeComposer");
composer.hidden = !composer.hidden;
if (!composer.hidden) $("#saveModeName").focus();
}
function saveCurrentModeProfile() {
const input = $("#saveModeName");
const rawName = input.value.trim();
if (!rawName) {
showToast("请输入模式名称");
return;
}
const modeName = rawName.endsWith("模式") ? rawName : `${rawName}模式`;
$("#utterance").value = `这个模式不错,固化为${modeName}`;
$("#saveModeComposer").hidden = true;
input.value = "";
$("#requestForm").requestSubmit();
}
async function loadRuntime() {
try {
const response = await fetch("/api/runtime", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
state.runtime = await response.json();
populateVehicleSelector();
populateModeChips();
const hardware = state.runtime.hardware || {};
const badge = $("#modelBadge");
const gptButton = $('[data-provider="openai"]');
@@ -3099,13 +3221,16 @@ function renderExecution(result) {
const reportSucceeded = report.spec.status === "succeeded";
summary.className = `execution-summary ${reportSucceeded ? "succeeded" : "failed"}`;
const presetSave = Boolean(result.preset_config);
const modeSave = Boolean(result.mode_profile);
const physical = report.spec.mode === "hil";
const executedDomains = [...new Set(report.spec.results.map((item) => item.domain))];
const motionCount = report.spec.results.filter((item) => item.telemetry?.motion_axis).length;
const firmwareResult = report.spec.results.find((item) => item.telemetry?.firmware_verified);
const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware");
let executionDetail = presetSave
? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令"
let executionDetail = modeSave
? `长期模式 ${result.mode_profile.name} 已写入配置库,未生成 CAN 命令`
: presetSave
? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令"
: physical
? `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
@@ -3132,7 +3257,7 @@ function renderExecution(result) {
? "真实台架未返回满足目标的反馈,移动链路已尝试发送 STOP_ALL。"
: "真实台架未返回满足目标的物理反馈。";
}
summary.innerHTML = `<i data-lucide="${reportSucceeded ? (presetSave ? "bookmark-check" : "circle-check-big") : "circle-x"}"></i><div><strong>${reportSucceeded ? (presetSave ? "姿态预设已保存" : (physical ? "真实台架执行成功" : "虚拟执行成功")) : "真实台架执行失败"}</strong><span>${executionDetail}</span></div>`;
summary.innerHTML = `<i data-lucide="${reportSucceeded ? ((presetSave || modeSave) ? "bookmark-check" : "circle-check-big") : "circle-x"}"></i><div><strong>${reportSucceeded ? (modeSave ? "长期模式已保存" : presetSave ? "姿态预设已保存" : (physical ? "真实台架执行成功" : "虚拟执行成功")) : "真实台架执行失败"}</strong><span>${executionDetail}</span></div>`;
$("#resultNavMeta").textContent = reportSucceeded
? `${report.spec.results.length} 项完成 · ${physical ? "HIL" : "DRY-RUN"}`
: "执行失败 · 查看回读";
@@ -3171,7 +3296,9 @@ function renderResult(result) {
ambient_light: "氛围灯",
};
$("#resultTitle").textContent = executionSucceeded
? result.preset_config
? result.mode_profile
? "长期模式配置已保存"
: result.preset_config
? "座椅姿态预设已保存"
: result.scene_config
? `${domains.length} 域场景闭环完成`
@@ -3192,6 +3319,7 @@ function renderResult(result) {
renderDomainState(result.domain_state);
updateTwinExplicitAngles(result);
renderSavedPresets(result);
if (result.mode_profile) loadRuntime();
renderCode(result);
renderExecution(result);
window.dispatchEvent(new CustomEvent("cognitive-agent:result", { detail: result }));
@@ -3226,6 +3354,7 @@ async function submitRequest(event) {
utterance,
provider: state.provider,
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
execution_mode: state.executionMode,
...(state.executionMode === "hardware"
? { approval_token: approvalToken }
@@ -3352,6 +3481,22 @@ document.addEventListener("DOMContentLoaded", async () => {
});
$("#newConversation").addEventListener("click", startNewConversation);
$("#logoutButton").addEventListener("click", logout);
$("#vehicleSelect").addEventListener("change", (event) => {
selectVehicle(event.target.value);
});
$(".mode-chips").addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const button = target?.closest("button[data-mode-command]");
if (button) applyModeCommand(button.dataset.modeCommand);
});
$("#saveModeToggle").addEventListener("click", toggleSaveModeComposer);
$("#saveModeConfirm").addEventListener("click", saveCurrentModeProfile);
$("#saveModeName").addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault();
saveCurrentModeProfile();
}
});
$$(".segment[data-provider]").forEach((button) => button.addEventListener("click", () => selectProvider(button.dataset.provider)));
$$('[data-execution-mode]').forEach((button) => button.addEventListener("click", () => selectExecutionMode(button.dataset.executionMode)));
$$(".example-command").forEach((button) => button.addEventListener("click", () => {
+13 -2
View File
@@ -10,7 +10,7 @@
<script src="https://unpkg.com/three@0.147.0/build/three.min.js" defer></script>
<script src="https://unpkg.com/three@0.147.0/examples/js/libs/fflate.min.js" defer></script>
<script src="https://unpkg.com/three@0.147.0/examples/js/loaders/FBXLoader.js" defer></script>
<script src="/app.js?v=unified-cabin-12" defer></script>
<script src="/app.js?v=unified-cabin-13" defer></script>
<script type="module" src="/gesture-control.js?v=gesture-core-3"></script>
</head>
<body>
@@ -51,7 +51,7 @@
<button type="button" data-mode-command="副驾驶调整为零重力模式"><i data-lucide="orbit"></i>零重力</button>
</div>
<div class="save-mode-composer" id="saveModeComposer" hidden>
<input id="saveModeName" maxlength="24" placeholder="给当前座椅姿态起个名字" />
<input id="saveModeName" maxlength="24" placeholder="给当前模式起个名字" />
<button id="saveModeConfirm" type="button">保存</button>
</div>
</div>
@@ -95,6 +95,17 @@
<p class="provider-note" id="hardwareAliasNote">主驾和副驾暂时共享同一组实体电机、加热与通风执行器</p>
</div>
<div class="field-group vehicle-field">
<label class="field-label" for="vehicleSelect">车型配置</label>
<div class="vehicle-select-control">
<i data-lucide="car-front"></i>
<select id="vehicleSelect" name="vehicle_id" aria-label="选择车型配置">
<option value="">加载车型配置</option>
</select>
</div>
<p class="provider-note" id="vehicleNote">正在读取座椅量程与角度标定</p>
</div>
<div class="field-group approval-field" id="approvalField" hidden>
<label class="field-label" for="approvalToken">硬件执行授权令牌</label>
<input id="approvalToken" name="approval_token" type="password" inputmode="numeric" maxlength="6" autocomplete="off" spellcheck="false" placeholder="请输入 123456" />
+31
View File
@@ -1086,6 +1086,37 @@ textarea:focus {
margin-top: 18px;
}
.vehicle-select-control {
min-height: 40px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 9px;
padding: 0 11px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface-muted);
}
.vehicle-select-control svg {
width: 17px;
height: 17px;
color: var(--teal);
}
.vehicle-select-control select {
min-width: 0;
width: 100%;
height: 38px;
border: 0;
outline: 0;
background: transparent;
color: var(--ink);
font: inherit;
font-size: 13px;
font-weight: 750;
}
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -0,0 +1,76 @@
id: baby_nap
name: 宝宝午睡模式
aliases:
- 宝宝午睡
description: 腾势 N7 宝宝午睡姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和极暗暖光能力。
icon: baby
scope:
vehicle_ids:
- denza_n7
operations:
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
operation: set
parameters:
position_pct: 19
reason: 宝宝午睡模式水平电机目标 40mm,按腾势 N7 215mm 行程换算。
- domain: seat
capability: seat.set_cushion_tilt
target_scope: passenger
operation: set
parameters:
position_pct: 30
reason: 宝宝午睡模式坐盆电机目标。
- domain: seat
capability: seat.set_backrest_recline
target_scope: passenger
operation: set
parameters:
position_pct: 41
angle_deg: 123
reason: 宝宝午睡模式靠背电机目标 28 度,按腾势 N7 显示夹角换算。
- domain: seat
capability: seat.set_legrest_height
target_scope: passenger
operation: set
parameters:
position_pct: 0
angle_deg: 95
reason: 宝宝午睡模式腿托电机目标 0 度。
- domain: climate
capability: climate.set_temperature
target_scope: vehicle
operation: set
parameters:
temperature_c: 24
reason: 全车恒温锁定 24 度。
- domain: climate
capability: climate.set_fan_level
target_scope: vehicle
operation: set
parameters:
level: 1
reason: 风量强制最低静音 1 档。
- domain: climate
capability: climate.set_cycle_mode
target_scope: vehicle
operation: set
parameters:
mode: internal
reason: 自动切换内循环。
- domain: roof
capability: roof.set_shade_position
target_scope: vehicle
operation: set
parameters:
position_pct: 10
reason: 全景天幕遮阳帘 90% 关闭,换算为 10% 打开。
- domain: ambient_light
capability: ambient_light.set_illumination
target_scope: vehicle
operation: set
parameters:
color_hex: "#FFF4E0"
brightness_pct: 5
reason: 极暗暖白光氛围。
@@ -0,0 +1,41 @@
id: calf_decompression_float
name: 小腿解压悬浮模式
aliases:
- 小腿解压
- 小腿悬浮
description: 腾势 N7 副驾小腿解压悬浮姿态。
icon: activity
scope:
vehicle_ids:
- denza_n7
operations:
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
operation: set
parameters:
position_pct: 49
reason: 小腿解压悬浮模式水平电机目标 105mm,按腾势 N7 215mm 行程换算。
- domain: seat
capability: seat.set_cushion_tilt
target_scope: passenger
operation: set
parameters:
position_pct: 75
reason: 小腿解压悬浮模式坐盆电机目标。
- domain: seat
capability: seat.set_backrest_recline
target_scope: passenger
operation: set
parameters:
position_pct: 0
angle_deg: 95
reason: 小腿解压悬浮模式靠背电机目标 0 度。
- domain: seat
capability: seat.set_legrest_height
target_scope: passenger
operation: set
parameters:
position_pct: 92
angle_deg: 167
reason: 小腿解压悬浮模式腿托电机目标 72 度,按腾势 N7 显示夹角换算。
@@ -0,0 +1,40 @@
id: cruise_sofa
name: 巡航沙发模式
aliases:
- 巡航沙发
description: 腾势 N7 副驾巡航沙发姿态。
icon: armchair
scope:
vehicle_ids:
- denza_n7
operations:
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
operation: set
parameters:
position_pct: 49
reason: 巡航沙发模式水平电机目标 105mm,按腾势 N7 215mm 行程换算。
- domain: seat
capability: seat.set_cushion_tilt
target_scope: passenger
operation: set
parameters:
position_pct: 30
reason: 巡航沙发模式坐盆电机目标。
- domain: seat
capability: seat.set_backrest_recline
target_scope: passenger
operation: set
parameters:
position_pct: 44
angle_deg: 125
reason: 巡航沙发模式靠背电机目标 30 度,按腾势 N7 显示夹角换算。
- domain: seat
capability: seat.set_legrest_height
target_scope: passenger
operation: set
parameters:
position_pct: 49
angle_deg: 133
reason: 巡航沙发模式腿托电机目标 38 度,按腾势 N7 显示夹角换算。
@@ -0,0 +1,40 @@
id: easy_entry_exit
name: 轻松进出模式
aliases:
- 轻松进出
description: 腾势 N7 副驾上下车进出姿态;关门后自动恢复坐好姿态需要门状态触发,当前暂不落库。
icon: log-in
scope:
vehicle_ids:
- denza_n7
operations:
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
operation: set
parameters:
position_pct: 0
reason: 轻松进出模式上下车水平电机目标 0mm。
- domain: seat
capability: seat.set_cushion_tilt
target_scope: passenger
operation: set
parameters:
position_pct: 0
reason: 轻松进出模式上下车坐盆电机目标。
- domain: seat
capability: seat.set_backrest_recline
target_scope: passenger
operation: set
parameters:
position_pct: 0
angle_deg: 95
reason: 轻松进出模式上下车靠背电机目标 0 度。
- domain: seat
capability: seat.set_legrest_height
target_scope: passenger
operation: set
parameters:
position_pct: 0
angle_deg: 95
reason: 轻松进出模式上下车腿托电机目标 0 度。
@@ -0,0 +1,62 @@
id: electric_beach_chair
name: 电感沙滩椅
aliases:
- 电感沙滩椅模式
- 沙滩椅模式
description: 腾势 N7 电感沙滩椅姿态,并联动当前已接入的恒温、天幕遮阳和出风能力。
icon: waves
scope:
vehicle_ids:
- denza_n7
operations:
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
operation: set
parameters:
position_pct: 0
reason: 电感沙滩椅水平电机目标 0mm。
- domain: seat
capability: seat.set_cushion_tilt
target_scope: passenger
operation: set
parameters:
position_pct: 0
reason: 电感沙滩椅坐盆电机目标。
- domain: seat
capability: seat.set_backrest_recline
target_scope: passenger
operation: set
parameters:
position_pct: 100
angle_deg: 163
reason: 电感沙滩椅靠背电机目标 68 度,按腾势 N7 显示夹角换算。
- domain: seat
capability: seat.set_legrest_height
target_scope: passenger
operation: set
parameters:
position_pct: 55
angle_deg: 138
reason: 电感沙滩椅腿托电机目标 43 度,按腾势 N7 显示夹角换算。
- domain: climate
capability: climate.set_temperature
target_scope: vehicle
operation: set
parameters:
temperature_c: 23
reason: 座舱锁定 23 度恒温。
- domain: climate
capability: climate.set_outlet_mode
target_scope: passenger
operation: set
parameters:
mode: face
reason: 水平上吹近似映射为当前能力支持的面部出风。
- domain: roof
capability: roof.set_shade_position
target_scope: vehicle
operation: set
parameters:
position_pct: 0
reason: 全景天幕遮阳帘 100% 完全关闭。
@@ -0,0 +1,83 @@
id: leisure_lounger
name: 休闲躺椅模式
aliases:
- 休闲躺椅
description: 腾势 N7 副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力。
icon: sofa
scope:
vehicle_ids:
- denza_n7
operations:
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
operation: set
parameters:
position_pct: 49
reason: 休闲躺椅模式水平电机目标 105mm,按腾势 N7 215mm 行程换算。
- domain: seat
capability: seat.set_cushion_tilt
target_scope: passenger
operation: set
parameters:
position_pct: 30
reason: 休闲躺椅模式坐盆电机目标。
- domain: seat
capability: seat.set_backrest_recline
target_scope: passenger
operation: set
parameters:
position_pct: 66
angle_deg: 140
reason: 休闲躺椅模式靠背电机目标 45 度,按腾势 N7 显示夹角换算。
- domain: seat
capability: seat.set_legrest_height
target_scope: passenger
operation: set
parameters:
position_pct: 71
angle_deg: 150
reason: 休闲躺椅模式腿托电机目标 55 度,按腾势 N7 显示夹角换算。
- domain: climate
capability: climate.set_temperature
target_scope: rear_right
operation: set
parameters:
temperature_c: 25
reason: 二排右侧温区调至 24-26 度舒适区间中值。
- domain: climate
capability: climate.set_fan_level
target_scope: vehicle
operation: set
parameters:
level: 4
reason: 空调风量中档。
- domain: climate
capability: climate.set_cycle_mode
target_scope: vehicle
operation: set
parameters:
mode: external
reason: 自动切换外循环。
- domain: climate
capability: climate.set_outlet_mode
target_scope: rear_right
operation: set
parameters:
mode: feet
reason: 当前能力支持吹脚,腰部出风暂未接入。
- domain: roof
capability: roof.set_shade_position
target_scope: vehicle
operation: set
parameters:
position_pct: 0
reason: 全景天幕遮阳帘完全关闭。
- domain: ambient_light
capability: ambient_light.set_illumination
target_scope: vehicle
operation: set
parameters:
color_hex: "#FFB347"
brightness_pct: 20
reason: 低亮度暖光氛围。
@@ -0,0 +1,84 @@
id: meditation_space
name: 冥想空间模式
aliases:
- 冥想空间
- 冥想
description: 腾势 N7 冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力。
icon: leaf
scope:
vehicle_ids:
- denza_n7
operations:
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
operation: set
parameters:
position_pct: 0
reason: 冥想空间模式水平电机目标 0mm。
- domain: seat
capability: seat.set_cushion_tilt
target_scope: passenger
operation: set
parameters:
position_pct: 44
reason: 冥想空间模式坐盆电机目标。
- domain: seat
capability: seat.set_backrest_recline
target_scope: passenger
operation: set
parameters:
position_pct: 44
angle_deg: 125
reason: 冥想空间模式靠背电机目标 30 度,按腾势 N7 显示夹角换算。
- domain: seat
capability: seat.set_legrest_height
target_scope: passenger
operation: set
parameters:
position_pct: 54
angle_deg: 137
reason: 冥想空间模式腿托电机目标 42 度,按腾势 N7 显示夹角换算。
- domain: climate
capability: climate.set_temperature
target_scope: vehicle
operation: set
parameters:
temperature_c: 23
reason: 恒温 22-24 度中值。
- domain: climate
capability: climate.set_fan_level
target_scope: vehicle
operation: set
parameters:
level: 1
reason: 低柔风量。
- domain: climate
capability: climate.set_cycle_mode
target_scope: vehicle
operation: set
parameters:
mode: internal
reason: 自动切换内循环。
- domain: roof
capability: roof.set_shade_position
target_scope: vehicle
operation: set
parameters:
position_pct: 0
reason: 全景天幕遮阳帘完全闭合遮光。
- domain: ambient_light
capability: ambient_light.set_mode
target_scope: vehicle
operation: set
parameters:
mode: meditation
reason: 切换矩阵定义的冥想氛围灯模式。
- domain: ambient_light
capability: ambient_light.set_illumination
target_scope: vehicle
operation: set
parameters:
color_hex: "#6FAF9D"
brightness_pct: 30
reason: 冥想柔光亮度约为日常亮度 30%。
+75
View File
@@ -0,0 +1,75 @@
id: nap
name: 小憩模式
aliases:
- 小憩
description: 腾势 N7 副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力。
icon: moon
scope:
vehicle_ids:
- denza_n7
operations:
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
operation: set
parameters:
position_pct: 100
reason: 小憩模式水平电机目标 215mm,按腾势 N7 215mm 行程换算。
- domain: seat
capability: seat.set_cushion_tilt
target_scope: passenger
operation: set
parameters:
position_pct: 0
reason: 小憩模式坐盆电机目标。
- domain: seat
capability: seat.set_backrest_recline
target_scope: passenger
operation: set
parameters:
position_pct: 0
angle_deg: 95
reason: 小憩模式靠背电机目标 0 度,按腾势 N7 显示夹角换算。
- domain: seat
capability: seat.set_legrest_height
target_scope: passenger
operation: set
parameters:
position_pct: 0
angle_deg: 95
reason: 小憩模式腿托电机目标 0 度,按腾势 N7 显示夹角换算。
- domain: climate
capability: climate.set_temperature
target_scope: vehicle
operation: set
parameters:
temperature_c: 23
reason: 自动设定 22-24 度恒温中值。
- domain: climate
capability: climate.set_fan_level
target_scope: vehicle
operation: set
parameters:
level: 1
reason: 低风静音档位。
- domain: climate
capability: climate.set_cycle_mode
target_scope: vehicle
operation: set
parameters:
mode: internal
reason: 自动切换内循环。
- domain: roof
capability: roof.set_shade_position
target_scope: vehicle
operation: set
parameters:
position_pct: 0
reason: 全景天幕遮阳帘关闭。
- domain: ambient_light
capability: ambient_light.set_power
target_scope: vehicle
operation: set
parameters:
state: "off"
reason: 氛围灯关闭,阅读灯等独立灯具暂未接入。
@@ -0,0 +1,83 @@
id: office
name: 办公模式
aliases:
- 办公
description: 腾势 N7 副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力。
icon: briefcase
scope:
vehicle_ids:
- denza_n7
operations:
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
operation: set
parameters:
position_pct: 28
reason: 办公模式水平电机目标 60mm,按腾势 N7 215mm 行程换算。
- domain: seat
capability: seat.set_cushion_tilt
target_scope: passenger
operation: set
parameters:
position_pct: 22
reason: 办公模式坐盆电机目标。
- domain: seat
capability: seat.set_backrest_recline
target_scope: passenger
operation: set
parameters:
position_pct: 22
angle_deg: 110
reason: 办公模式靠背电机目标 15 度,按腾势 N7 显示夹角换算。
- domain: seat
capability: seat.set_legrest_height
target_scope: passenger
operation: set
parameters:
position_pct: 19
angle_deg: 110
reason: 办公模式腿托电机目标 15 度,按腾势 N7 显示夹角换算。
- domain: climate
capability: climate.set_temperature
target_scope: passenger
operation: set
parameters:
temperature_c: 24.5
reason: 办公座位专属恒温 24.5 度。
- domain: climate
capability: climate.set_fan_level
target_scope: vehicle
operation: set
parameters:
level: 3
reason: 中档静音风量。
- domain: climate
capability: climate.set_cycle_mode
target_scope: vehicle
operation: set
parameters:
mode: internal
reason: 自动切换内循环。
- domain: climate
capability: climate.set_outlet_mode
target_scope: passenger
operation: set
parameters:
mode: face
reason: 水平上吹近似映射为当前能力支持的面部出风。
- domain: roof
capability: roof.set_shade_position
target_scope: vehicle
operation: set
parameters:
position_pct: 30
reason: 全景天幕遮阳帘 70% 半关闭,换算为 30% 打开。
- domain: ambient_light
capability: ambient_light.set_illumination
target_scope: vehicle
operation: set
parameters:
color_hex: "#E8F3FF"
brightness_pct: 70
reason: 办公冷白光氛围。
@@ -0,0 +1,69 @@
id: queen_passenger_driving
name: 女王副驾(行驶)
aliases:
- 女王副驾
- 女王副驾模式
description: 腾势 N7 女王副驾行驶姿态,并联动当前已接入的副驾恒温、低风、内循环和出风能力。
icon: crown
scope:
vehicle_ids:
- denza_n7
operations:
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
operation: set
parameters:
position_pct: 28
reason: 女王副驾水平电机目标 60mm,按腾势 N7 215mm 行程换算。
- domain: seat
capability: seat.set_cushion_tilt
target_scope: passenger
operation: set
parameters:
position_pct: 38
reason: 女王副驾坐盆电机目标 37.5%,按整数百分比四舍五入。
- domain: seat
capability: seat.set_backrest_recline
target_scope: passenger
operation: set
parameters:
position_pct: 44
angle_deg: 125
reason: 女王副驾靠背电机目标 30 度,按腾势 N7 显示夹角换算。
- domain: seat
capability: seat.set_legrest_height
target_scope: passenger
operation: set
parameters:
position_pct: 28
angle_deg: 117
reason: 女王副驾腿托电机目标 22 度,按腾势 N7 显示夹角换算。
- domain: climate
capability: climate.set_temperature
target_scope: passenger
operation: set
parameters:
temperature_c: 23.5
reason: 副驾恒温锁定 23.5 度。
- domain: climate
capability: climate.set_fan_level
target_scope: vehicle
operation: set
parameters:
level: 1
reason: 风量强制最低静音 1 档。
- domain: climate
capability: climate.set_cycle_mode
target_scope: vehicle
operation: set
parameters:
mode: internal
reason: 自动切换内循环。
- domain: climate
capability: climate.set_outlet_mode
target_scope: passenger
operation: set
parameters:
mode: face
reason: 副驾水平上吹近似映射为当前能力支持的面部出风。
@@ -0,0 +1,43 @@
id: zero_gravity
name: 零重力模式
aliases:
- 零重力
- 零重力模式
- zero gravity
- zero-gravity
description: 副驾零重力展示姿态,使用腾势 N7 当前座椅角度标定生成座椅 CAN 目标。
icon: orbit
scope:
vehicle_ids:
- denza_n7
operations:
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
operation: set
parameters:
position_pct: 0
reason: 零重力模式水平电机目标 0mm,按腾势 N7 215mm 行程换算。
- domain: seat
capability: seat.set_cushion_tilt
target_scope: passenger
operation: set
parameters:
position_pct: 100
reason: 零重力模式坐盆电机目标。
- domain: seat
capability: seat.set_backrest_recline
target_scope: passenger
operation: set
parameters:
position_pct: 81
angle_deg: 150
reason: 零重力模式靠背电机目标 55 度,按腾势 N7 显示夹角换算。
- domain: seat
capability: seat.set_legrest_height
target_scope: passenger
operation: set
parameters:
position_pct: 87
angle_deg: 163
reason: 零重力模式腿托电机目标 68 度,按腾势 N7 显示夹角换算。
+42
View File
@@ -0,0 +1,42 @@
id: denza_n7
name: 腾势 N7
seat:
motors:
longitudinal:
name: 水平
unit: mm
range: 215
reference_zero: rearmost
positive_direction: forward
hall_total_pulses: 480
cushion_tilt:
name: 坐盆
unit: percent
range: 100
reference_zero: lowest
positive_direction: up
hall_total_pulses: 374
backrest_recline:
name: 座椅靠背
unit: degree
range: 68
reference_zero: upright
positive_direction: recline
hall_total_pulses: 917
display_angle:
kind: included_angle
zero_deg: 95
max_deg: 163
provisional: true
legrest_height:
name: 腿托
unit: degree
range: 78
reference_zero: retracted
positive_direction: raise
hall_total_pulses: 647
display_angle:
kind: included_angle
zero_deg: 95
max_deg: 173
provisional: true
@@ -0,0 +1,26 @@
# Mode Profile Library Plan
## Goal
Add a long-term cabin mode configuration layer that can be matched from natural-language requests before normal CAN compilation.
## Scope
- Add repository-backed mode YAML files under `config/modes/`.
- Convert matched mode operations into the same model-plan requests already used by seat, climate, roof, and ambient-light adapters.
- Preserve current behavior for unmatched or extra user goals by letting the AI/local parser generate temporary requests.
- Allow a recent successful action set to be saved as a long-term mode profile when the user explicitly asks to solidify a mode.
- Expose available modes to the web console and wire the existing quick-mode/save controls to the new workflow.
## Tests First
- Unit-test mode profile loading, matching, request conversion, and save-to-YAML behavior.
- Integration-test zero-gravity matching alone.
- Integration-test zero-gravity plus a temporary roof request producing a cross-domain `SceneConfig`.
- Integration-test saving the previous successful action set as a long-term mode file without emitting CAN.
## Design Notes
- The mode library stores semantic operations, not CAN frames. CAN remains generated by the existing domain compilers.
- Seat-related operations in modes still pass through vehicle-profile angle/percentage calibration.
- Save-mode detection is intentionally narrower than seat posture preset saving to avoid changing existing preset behavior.
@@ -0,0 +1,126 @@
# Vehicle Profile Seat Calibration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add vehicle-selectable Denza N7 seat calibration so natural-language physical deltas can be deterministically converted to absolute seat `position_pct` before CAN compilation.
**Architecture:** Add a small vehicle profile layer under `src/cognitive_agent/application/` that loads YAML profiles from `config/vehicles/`. `SeatDemoService` passes the selected profile into model-plan adaptation and returns available profiles via `/api/runtime`; the frontend stores a selected vehicle id and includes it in `/api/requests`. CAN compilers remain unchanged and continue receiving absolute `position_pct`.
**Tech Stack:** Python 3.11 stdlib + PyYAML, existing unittest suite, existing static HTML/JS frontend.
---
### Task 1: Vehicle Profile Loader
**Files:**
- Create: `config/vehicles/denza_n7.yaml`
- Create: `src/cognitive_agent/application/vehicle_profile.py`
- Test: `tests/unit/test_vehicle_profile.py`
- [ ] **Step 1: Write failing tests**
Create `tests/unit/test_vehicle_profile.py` with tests that load the Denza N7 profile, expose all four motors, convert relative physical deltas into target percentages, and expose angle ranges for frontend display.
- [ ] **Step 2: Run tests and verify RED**
Run: `PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src:domains/seat/src .omx/python311-venv/bin/python -m unittest tests.unit.test_vehicle_profile -v`
Expected: fails because `cognitive_agent.application.vehicle_profile` does not exist.
- [ ] **Step 3: Implement loader**
Create the Denza N7 YAML profile with `longitudinal`, `cushion_tilt`, `backrest_recline`, and `legrest_height` motors. Implement `VehicleProfileRegistry`, `VehicleProfile`, and `SeatMotorProfile` with methods to compute clamped absolute targets from relative deltas.
- [ ] **Step 4: Run tests and verify GREEN**
Run the same unittest command and expect PASS.
### Task 2: Apply Vehicle Profile During Intent Adaptation
**Files:**
- Modify: `src/cognitive_agent/application/model_plan_adapter.py`
- Test: `tests/unit/test_vehicle_profile.py`
- [ ] **Step 1: Write failing tests**
Add tests showing a request with `distance_mm=50` and `operation=increase` for `seat.set_longitudinal_position` converts from current 50% to 73% using Denza N7's 215mm range. Add a test showing `angle_delta_deg=10` for `seat.set_backrest_recline` converts from 20% to 38% using the 55 degree range and preserves `motion_intent`.
- [ ] **Step 2: Run tests and verify RED**
Run the vehicle profile unittest and expect failure because `ModelPlanAdapter.adapt()` does not accept a vehicle profile and rejects unknown `distance_mm` / `angle_delta_deg` parameters.
- [ ] **Step 3: Implement adaptation**
Allow vehicle-profile-backed physical delta parameters for seat motion requests. Translate them to absolute `position_pct`, preserve relative motion metadata, and keep the compiler-facing `FunctionConfig` unchanged.
- [ ] **Step 4: Run tests and verify GREEN**
Run the vehicle profile unittest and expect PASS.
### Task 3: Thread Vehicle Selection Through API and Runtime
**Files:**
- Modify: `src/cognitive_agent/application/seat_service.py`
- Modify: `src/cognitive_agent/server.py`
- Test: `tests/e2e/test_seat_web_api.py`
- [ ] **Step 1: Write failing tests**
Add e2e coverage that `/api/runtime` includes `vehicles` and `default_vehicle_id`, and that a POST containing `vehicle_id: "denza_n7"` records the selected profile in the result and uses it for conversion.
- [ ] **Step 2: Run tests and verify RED**
Run: `PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src:domains/seat/src .omx/python311-venv/bin/python -m unittest tests.e2e.test_seat_web_api -v`
Expected: fails because runtime has no vehicles and POST ignores `vehicle_id`.
- [ ] **Step 3: Implement API threading**
Instantiate `VehicleProfileRegistry` in `SeatDemoService`, return public profiles from `runtime_info()`, accept `vehicle_id` in `process()`, pass the selected profile to `ModelPlanAdapter`, and include `vehicle_id` / `vehicle_name` in the API result. Update `server.py` to read `vehicle_id` from JSON.
- [ ] **Step 4: Run tests and verify GREEN**
Run the e2e unittest and expect PASS.
### Task 4: Add Frontend Vehicle Selector
**Files:**
- Modify: `apps/web-console/index.html`
- Modify: `apps/web-console/app.js`
- Modify: `apps/web-console/styles.css`
- Test: `tests/e2e/test_seat_web_api.py`
- [ ] **Step 1: Write failing tests**
Extend the static HTML/runtime e2e test to assert that the page has a vehicle selector and that runtime-provided vehicles are expected by the frontend.
- [ ] **Step 2: Run tests and verify RED**
Run the e2e unittest and expect failure because the selector does not exist.
- [ ] **Step 3: Implement UI**
Add a compact selector in the top bar or command panel, populate it from `/api/runtime.vehicles`, keep `denza_n7` selected by default, store the selected id in `state.vehicleId`, and include `vehicle_id` in POST `/api/requests`.
- [ ] **Step 4: Run tests and verify GREEN**
Run the e2e unittest and expect PASS.
### Task 5: Verification
**Files:**
- No new production files beyond Tasks 1-4.
- [ ] **Step 1: Run targeted tests**
Run:
`PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src:domains/seat/src .omx/python311-venv/bin/python -m unittest tests.unit.test_vehicle_profile domains.seat.tests.unit.test_t2g_hardware tests.e2e.test_seat_web_api -v`
- [ ] **Step 2: Smoke running service**
Use the existing Python 3.11 venv to GET `/api/runtime` and verify Denza N7 appears. POST a local/direct request with `vehicle_id=denza_n7` and confirm a successful dry-run still returns a `CommandPlan`.
- [ ] **Step 3: Review diff**
Run `git diff --stat` and inspect changed files. Do not revert the pre-existing `README.md` change unless explicitly requested.
+34 -7
View File
@@ -65,7 +65,9 @@ spec:
description: >-
Set one front seat's fore-aft rail position. position_pct is an absolute target: 0 is the
rearmost mechanical limit and 100 is the forwardmost limit. Resolve relative requests such
as "move back a little" against the current position supplied in runtime state.
as "move back a little" against the current position supplied in runtime state. For a
vehicle-specific physical relative request, distance_mm may be supplied and the runtime
converts it to an absolute position_pct before CAN compilation.
target_devices:
- seat_longitudinal_motor
target_scopes:
@@ -75,10 +77,17 @@ spec:
- name: position_pct
type: integer
unit: percent
required: true
required: false
minimum: 0
maximum: 100
description: Absolute normalized fore-aft target.
- name: distance_mm
type: number
unit: mm
required: false
minimum: 0
maximum: 215
description: Relative physical fore-aft movement resolved against current position and vehicle calibration.
risk_level: high
confirmation_policy: on_ambiguity
- name: seat.set_cushion_tilt
@@ -105,7 +114,9 @@ spec:
Set the backrest recline of one front seat. Use either position_pct or angle_deg.
position_pct is absolute: 0 is the most upright limit and 100 is the most reclined limit.
angle_deg is the included angle between the seat-cushion surface and backrest centreline,
from 95 to 140 degrees. "Backrest backward" means a larger value.
from 95 to 163 degrees for the selected Denza N7 profile. "Backrest backward" means a
larger value. For relative physical angle requests, angle_delta_deg may be supplied and the
runtime converts it to an absolute position_pct before CAN compilation.
target_devices:
- seat_backrest_motor
target_scopes:
@@ -124,16 +135,25 @@ spec:
unit: degree
required: false
minimum: 95
maximum: 140
maximum: 163
description: Exact included angle between the cushion surface and backrest centreline.
- name: angle_delta_deg
type: number
unit: degree
required: false
minimum: 0
maximum: 68
description: Relative backrest angle movement resolved against current posture and vehicle calibration.
risk_level: high
confirmation_policy: on_ambiguity
- name: seat.set_legrest_height
description: >-
Raise or lower one front seat's powered leg rest. Use either position_pct or angle_deg.
position_pct is absolute: 0 is fully lowered and 100 is fully raised. angle_deg is the
included angle between the cushion surface and leg-rest surface, from 95 to 160 degrees.
This axis does not change leg-rest extension.
included angle between the cushion surface and leg-rest surface, from 95 to 173 degrees for
the selected Denza N7 profile. For relative physical angle requests, angle_delta_deg may be
supplied and the runtime converts it to an absolute position_pct before CAN compilation. This
axis does not change leg-rest extension.
target_devices:
- seat_legrest_lift_motor
target_scopes:
@@ -152,8 +172,15 @@ spec:
unit: degree
required: false
minimum: 95
maximum: 160
maximum: 173
description: Exact included angle between the cushion surface and leg-rest surface.
- name: angle_delta_deg
type: number
unit: degree
required: false
minimum: 0
maximum: 78
description: Relative leg-rest lift angle movement resolved against current posture and vehicle calibration.
risk_level: high
confirmation_policy: on_ambiguity
- name: seat.set_legrest_extension
@@ -61,7 +61,7 @@
"required": ["position_pct"],
"properties": {
"position_pct": {"type": "integer", "minimum": 0, "maximum": 100},
"angle_deg": {"type": "number", "minimum": 95, "maximum": 160}
"angle_deg": {"type": "number", "minimum": 95, "maximum": 173}
}
}
}
+2 -2
View File
@@ -16,8 +16,8 @@ SUPPORTED_MOTION_AXES = {
"legrest_extension",
}
ANGLE_RANGES = {
"backrest_recline": (95, 140),
"legrest_height": (95, 160),
"backrest_recline": (95, 163),
"legrest_height": (95, 173),
}
@@ -376,6 +376,105 @@ class SeatServiceIntegrationTests(unittest.TestCase):
40,
)
def test_model_relative_physical_motion_uses_vehicle_profile(self) -> None:
model_output = {
"detected_domains": ["seat"],
"requests": [
{
"domain": "seat",
"capability": "seat.set_longitudinal_position",
"target_scope": "driver",
"operation": "increase",
"parameters": [{"name": "distance_mm", "value": 50}],
"reason": "Move forward by a physical distance.",
},
{
"domain": "seat",
"capability": "seat.set_backrest_recline",
"target_scope": "driver",
"operation": "increase",
"parameters": [{"name": "angle_delta_deg", "value": 10}],
"reason": "Recline by a relative physical angle.",
},
],
"ambiguities": [],
"decision_summary": ["使用车型量程把相对物理量换算为绝对百分比。"],
"confidence": 0.94,
}
self.service.openai.api_key = "test-key"
with patch.object(self.service.openai, "interpret", return_value=model_output) as interpret:
result = self.service.process(
"主驾水平向前移动50毫米,靠背往后10度",
"openai",
"conv-vehicle-profile-relative",
vehicle_id="denza_n7",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["vehicle"]["id"], "denza_n7")
operations = result["function_config"]["spec"]["operations"]
self.assertEqual(operations[0]["parameters"], {"position_pct": 73})
self.assertEqual(
operations[1]["parameters"],
{"position_pct": 38, "angle_deg": 116},
)
backrest_motion = operations[1]["motion"]
self.assertEqual(backrest_motion["mode"], "relative")
self.assertEqual(backrest_motion["direction"], "increase")
self.assertEqual(backrest_motion["start_position_pct"], 20)
self.assertEqual(backrest_motion["delta_position_pct"], 18)
self.assertEqual(backrest_motion["start_angle_deg"], 106.0)
self.assertEqual(backrest_motion["delta_angle_deg"], 10)
context_sent_to_model = interpret.call_args.args[1]
self.assertEqual(context_sent_to_model["vehicle"]["id"], "denza_n7")
self.assertEqual(
context_sent_to_model["devices"]["driver"]["position"][
"backrest_recline"
]["angle_deg"],
106.0,
)
def test_vehicle_profile_angle_limits_are_accepted(self) -> None:
model_output = {
"detected_domains": ["seat"],
"requests": [
{
"domain": "seat",
"capability": "seat.set_backrest_recline",
"target_scope": "passenger",
"operation": "set",
"parameters": [{"name": "angle_deg", "value": 163}],
"reason": "Use the vehicle-specific maximum backrest angle.",
},
{
"domain": "seat",
"capability": "seat.set_legrest_height",
"target_scope": "passenger",
"operation": "set",
"parameters": [{"name": "angle_deg", "value": 173}],
"reason": "Use the vehicle-specific maximum legrest angle.",
},
],
"ambiguities": [],
"decision_summary": ["车型角度边界按腾势 N7 配置执行。"],
"confidence": 0.95,
}
self.service.openai.api_key = "test-key"
with patch.object(self.service.openai, "interpret", return_value=model_output):
result = self.service.process(
"副驾靠背到163度,腿托到173度",
"openai",
"conv-vehicle-angle-limits",
vehicle_id="denza_n7",
)
self.assertEqual(result["status"], "succeeded")
operations = result["function_config"]["spec"]["operations"]
self.assertEqual(operations[0]["parameters"], {"position_pct": 100, "angle_deg": 163})
self.assertEqual(operations[1]["parameters"], {"position_pct": 100, "angle_deg": 173})
def test_motion_adapter_does_not_resolve_missing_absolute_target(self) -> None:
model_output = {
"detected_domains": ["seat"],
@@ -527,11 +626,11 @@ class SeatServiceIntegrationTests(unittest.TestCase):
first_operations = first["function_config"]["spec"]["operations"]
self.assertEqual(
first_operations[0]["parameters"],
{"position_pct": 73, "angle_deg": 128},
{"position_pct": 49, "angle_deg": 128},
)
self.assertEqual(
first_operations[1]["parameters"],
{"position_pct": 58, "angle_deg": 133},
{"position_pct": 49, "angle_deg": 133},
)
self.assertEqual(
first["actuator_state"]["passenger"]["position"]["backrest_recline"][
@@ -541,7 +640,7 @@ class SeatServiceIntegrationTests(unittest.TestCase):
)
first_telemetry = first["execution_report"]["spec"]["results"][0]["telemetry"]
self.assertEqual(first_telemetry["requested_angle_deg"], 128)
self.assertEqual(first_telemetry["effective_position_pct"], 73)
self.assertEqual(first_telemetry["effective_position_pct"], 49)
self.assertEqual(
second["actuator_state"]["passenger"]["position"]["backrest_recline"][
@@ -554,8 +653,8 @@ class SeatServiceIntegrationTests(unittest.TestCase):
]
self.assertEqual(relative_motion["mode"], "relative")
self.assertEqual(relative_motion["direction"], "increase")
self.assertEqual(relative_motion["start_position_pct"], 73)
self.assertEqual(relative_motion["delta_position_pct"], 11)
self.assertEqual(relative_motion["start_position_pct"], 49)
self.assertEqual(relative_motion["delta_position_pct"], 7)
self.assertEqual(relative_motion["delta_angle_deg"], 5)
second_context = interpret.call_args_list[1].args[1]
self.assertEqual(
@@ -598,6 +598,11 @@ class T2GHardwareTests(unittest.TestCase):
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "true",
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN": "bench-secret",
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES": "combined",
"COGNITIVE_AGENT_SEAT_CAN_BACKEND": "python-can",
"COGNITIVE_AGENT_SEAT_CAN_BITRATE": "500000",
"COGNITIVE_AGENT_SEAT_CAN_INTERFACE": "socketcan",
"COGNITIVE_AGENT_SEAT_CAN_CHANNEL": "0",
"COGNITIVE_AGENT_SEAT_AUTO_FLASH_ENABLED": "false",
"COGNITIVE_AGENT_ENABLE_UNVERIFIED_LONGITUDINAL": "false",
}
with patch.dict(os.environ, environment, clear=False):
@@ -339,6 +339,55 @@ class ConversationStateStore:
self._persist_presets(conversation_id, state["presets"])
return deepcopy(state)
def save_mode_profile(
self,
conversation_id: str,
utterance: str,
mode_profile: dict[str, Any],
source_actions: list[dict[str, Any]],
decision_summary: list[str],
) -> dict[str, Any]:
with self._lock:
state = self._state(conversation_id)
state["turn_count"] += 1
state["updated_at"] = _now()
state["recent_turns"].append(
{
"utterance": utterance,
"actions": deepcopy(source_actions),
"decision_summary": list(decision_summary),
"control_event": "mode_profile_save",
"mode_profile": deepcopy(mode_profile),
}
)
state["recent_turns"] = state["recent_turns"][-6:]
return deepcopy(state)
def record_mode_profile_event(
self,
conversation_id: str,
utterance: str,
mode_profile: dict[str, Any],
source_actions: list[dict[str, Any]],
decision_summary: list[str],
control_event: str,
) -> dict[str, Any]:
with self._lock:
state = self._state(conversation_id)
state["turn_count"] += 1
state["updated_at"] = _now()
state["recent_turns"].append(
{
"utterance": utterance,
"actions": deepcopy(source_actions),
"decision_summary": list(decision_summary),
"control_event": control_event,
"mode_profile": deepcopy(mode_profile),
}
)
state["recent_turns"] = state["recent_turns"][-6:]
return deepcopy(state)
def reset(self, conversation_id: str) -> dict[str, Any]:
with self._lock:
state = _new_state(conversation_id, self._load_presets(conversation_id))
@@ -0,0 +1,435 @@
"""Repository-backed cabin mode profiles."""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
_WHITESPACE = re.compile(r"\s+")
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
def _normalize_match_text(value: str) -> str:
return _WHITESPACE.sub("", value.casefold())
def _clean_name(value: str) -> str:
cleaned = value.strip().strip(",。,.!!")
if not cleaned:
raise ValueError("mode name must not be empty")
if len(cleaned) > 64:
raise ValueError("mode name must be at most 64 characters")
return cleaned
def _mode_aliases(name: str, aliases: list[str] | None = None) -> list[str]:
values = [name, *(aliases or [])]
if name.endswith("模式") and len(name) > 2:
values.append(name.removesuffix("模式"))
else:
values.append(f"{name}模式")
normalized: set[str] = set()
unique = []
for value in values:
candidate = str(value).strip()
key = _normalize_match_text(candidate)
if not candidate or key in normalized:
continue
normalized.add(key)
unique.append(candidate)
return unique
def _slugify_mode_id(name: str) -> str:
ascii_slug = _NON_ASCII_SLUG.sub("-", name.casefold()).strip("-")
digest = hashlib.sha256(name.encode("utf-8")).hexdigest()[:10]
if ascii_slug:
return ascii_slug if len(ascii_slug) >= 3 else f"{ascii_slug}-{digest[:6]}"
return f"mode-{digest}"
@dataclass(frozen=True)
class ModeOperation:
domain: str
capability: str
target_scope: str
parameters: dict[str, Any]
operation: str = "set"
reason: str = ""
@classmethod
def from_dict(cls, document: dict[str, Any]) -> "ModeOperation":
parameters = document.get("parameters", {})
if not isinstance(parameters, dict):
raise ValueError("mode operation parameters must be an object")
return cls(
domain=str(document["domain"]),
capability=str(document["capability"]),
target_scope=str(document["target_scope"]),
operation=str(document.get("operation", "set")),
parameters=dict(parameters),
reason=str(document.get("reason", "")),
)
def to_request(self, mode_name: str) -> dict[str, Any]:
return {
"domain": self.domain,
"capability": self.capability,
"target_scope": self.target_scope,
"operation": self.operation,
"parameters": [
{"name": name, "value": value}
for name, value in self.parameters.items()
],
"reason": self.reason or f"Matched mode profile {mode_name}.",
}
def to_yaml(self) -> dict[str, Any]:
return {
"domain": self.domain,
"capability": self.capability,
"target_scope": self.target_scope,
"operation": self.operation,
"parameters": dict(self.parameters),
"reason": self.reason,
}
@dataclass(frozen=True)
class ModeProfile:
id: str
name: str
aliases: tuple[str, ...]
description: str
icon: str
operations: tuple[ModeOperation, ...]
source: str
common: bool = True
vehicle_ids: tuple[str, ...] = ()
@classmethod
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
document = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(document, dict):
raise ValueError(f"{path} must contain a YAML object")
operations = document.get("operations", [])
if not isinstance(operations, list) or not operations:
raise ValueError(f"{path} must contain at least one operation")
name = _clean_name(str(document["name"]))
common, vehicle_ids = _mode_scope(document, path, profile_root)
aliases = _mode_aliases(
name,
[str(alias) for alias in document.get("aliases", [])],
)
return cls(
id=str(document["id"]),
name=name,
aliases=tuple(aliases),
description=str(document.get("description", "")),
icon=str(document.get("icon", "bookmark")),
operations=tuple(ModeOperation.from_dict(item) for item in operations),
source=str(path),
common=common,
vehicle_ids=vehicle_ids,
)
def public_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"aliases": list(self.aliases),
"description": self.description,
"icon": self.icon,
"domains": sorted({operation.domain for operation in self.operations}),
"operation_count": len(self.operations),
"scope": {
"common": self.common,
"vehicle_ids": list(self.vehicle_ids),
},
}
class ModeProfileRegistry:
"""Load, match, and persist long-term cabin mode profiles."""
def __init__(self, profile_root: Path):
self.profile_root = Path(profile_root)
self._profiles: list[ModeProfile] = []
self.reload()
def reload(self) -> None:
if not self.profile_root.exists():
self._profiles = []
return
self._profiles = [
ModeProfile.from_yaml(path, self.profile_root)
for path in sorted(self.profile_root.rglob("*.yaml"))
if path.is_file()
]
def profiles(self) -> list[ModeProfile]:
return list(self._profiles)
def public_modes(self, vehicle_id: str | None = None) -> list[dict[str, Any]]:
return [
profile.public_dict()
for profile in self._profiles_for_vehicle(vehicle_id)
]
def match_text(
self,
utterance: str,
vehicle_id: str | None = None,
) -> list[ModeProfile]:
haystack = _normalize_match_text(utterance)
selected = {
profile.id: profile
for profile in self._profiles_for_vehicle(vehicle_id)
}
matches = []
matched_ids: set[str] = set()
for profile in self._visible_candidates_for_vehicle(vehicle_id):
candidates = [profile.id, profile.name, *profile.aliases]
if any(
candidate
and _normalize_match_text(str(candidate)) in haystack
for candidate in candidates
):
if profile.id in matched_ids:
continue
matched_ids.add(profile.id)
matches.append(selected[profile.id])
return matches
@staticmethod
def requests_for_profiles(profiles: list[ModeProfile]) -> list[dict[str, Any]]:
return [
operation.to_request(profile.name)
for profile in profiles
for operation in profile.operations
]
def save_mode(
self,
name: str,
operations: list[dict[str, Any] | ModeOperation],
mode_id: str | None = None,
aliases: list[str] | None = None,
vehicle_id: str | None = None,
) -> ModeProfile:
clean_name = _clean_name(name)
canonical_operations = [
operation
if isinstance(operation, ModeOperation)
else ModeOperation.from_dict(operation)
for operation in operations
]
if not canonical_operations:
raise ValueError("mode profile must contain at least one operation")
profile_id = mode_id or _slugify_mode_id(clean_name)
scope = (
{"vehicle_ids": [vehicle_id]}
if vehicle_id
else {"common": True, "vehicle_ids": []}
)
document = {
"id": profile_id,
"name": clean_name,
"aliases": _mode_aliases(clean_name, aliases),
"description": "用户固化的长期模式配置。",
"icon": "bookmark",
"scope": scope,
"operations": [
operation.to_yaml() for operation in canonical_operations
],
}
path = (
self.profile_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
if vehicle_id
else self.profile_root / "common" / "user" / f"{profile_id}.yaml"
)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
temporary.replace(path)
self.reload()
return ModeProfile.from_yaml(path, self.profile_root)
def update_mode(
self,
profile: ModeProfile,
operations: list[dict[str, Any] | ModeOperation],
) -> ModeProfile:
canonical_updates = _canonical_operations(operations)
if not canonical_updates:
raise ValueError("mode profile update must contain at least one operation")
path = Path(profile.source)
if not path.is_file():
raise ValueError(f"mode profile source does not exist: {path}")
backup_path = _initial_backup_path(path)
if not backup_path.exists():
backup_path.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
merged_operations = _merge_operations(profile.operations, canonical_updates)
document = _mode_profile_document(profile, merged_operations)
_replace_yaml(path, document)
self.reload()
return ModeProfile.from_yaml(path, self.profile_root)
def restore_initial_mode(self, profile: ModeProfile) -> ModeProfile:
path = Path(profile.source)
backup_path = _initial_backup_path(path)
if not backup_path.is_file():
raise ValueError(f"no initial mode backup found: {backup_path}")
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(backup_path.read_text(encoding="utf-8"), encoding="utf-8")
temporary.replace(path)
self.reload()
return ModeProfile.from_yaml(path, self.profile_root)
def _profiles_for_vehicle(self, vehicle_id: str | None) -> list[ModeProfile]:
selected: dict[str, ModeProfile] = {}
order: list[str] = []
for profile in self._visible_candidates_for_vehicle(vehicle_id):
if profile.id not in selected:
order.append(profile.id)
selected[profile.id] = profile
continue
current = selected[profile.id]
if _profile_rank(profile, vehicle_id) >= _profile_rank(current, vehicle_id):
selected[profile.id] = profile
return [selected[profile_id] for profile_id in order]
def _visible_candidates_for_vehicle(
self,
vehicle_id: str | None,
) -> list[ModeProfile]:
return [
profile
for profile in self._profiles
if _is_visible_for_vehicle(profile, vehicle_id)
]
def _canonical_operations(
operations: list[dict[str, Any] | ModeOperation],
) -> list[ModeOperation]:
return [
operation
if isinstance(operation, ModeOperation)
else ModeOperation.from_dict(operation)
for operation in operations
]
def _operation_signature(operation: ModeOperation) -> tuple[str, str, str]:
return (operation.domain, operation.capability, operation.target_scope)
def _merge_operations(
existing: tuple[ModeOperation, ...],
updates: list[ModeOperation],
) -> list[ModeOperation]:
merged = list(existing)
positions = {
_operation_signature(operation): index
for index, operation in enumerate(merged)
}
for operation in updates:
signature = _operation_signature(operation)
index = positions.get(signature)
if index is None:
positions[signature] = len(merged)
merged.append(operation)
continue
merged[index] = operation
return merged
def _initial_backup_path(path: Path) -> Path:
return path.with_suffix(path.suffix + ".initial.bak")
def _mode_profile_document(
profile: ModeProfile,
operations: list[ModeOperation],
) -> dict[str, Any]:
scope: dict[str, Any] = {
"common": profile.common,
"vehicle_ids": list(profile.vehicle_ids),
}
return {
"id": profile.id,
"name": profile.name,
"aliases": list(profile.aliases),
"description": profile.description,
"icon": profile.icon,
"scope": scope,
"operations": [operation.to_yaml() for operation in operations],
}
def _replace_yaml(path: Path, document: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
temporary.replace(path)
def _mode_scope(
document: dict[str, Any],
path: Path,
profile_root: Path | None,
) -> tuple[bool, tuple[str, ...]]:
scope = document.get("scope")
if isinstance(scope, dict):
vehicle_ids = tuple(
str(vehicle_id).strip()
for vehicle_id in scope.get("vehicle_ids", [])
if str(vehicle_id).strip()
)
common = bool(scope.get("common", not vehicle_ids))
return common, vehicle_ids
if profile_root is not None:
try:
parts = path.relative_to(profile_root).parts
except ValueError:
parts = ()
if len(parts) >= 3 and parts[0] == "vehicles":
return False, (parts[1],)
if parts and parts[0] == "common":
return True, ()
return True, ()
def _is_visible_for_vehicle(
profile: ModeProfile,
vehicle_id: str | None,
) -> bool:
if vehicle_id is None:
return True
return profile.common or vehicle_id in profile.vehicle_ids
def _profile_rank(profile: ModeProfile, vehicle_id: str | None) -> int:
if vehicle_id is not None and vehicle_id in profile.vehicle_ids:
return 2
if profile.common:
return 1
return 0
@@ -10,6 +10,7 @@ from cognitive_agent.application.seat_calibration import (
ANGLE_CALIBRATIONS,
angle_to_position_pct,
)
from cognitive_agent.application.vehicle_profile import VehicleProfile
SEAT_CAPABILITIES = {
@@ -23,6 +24,7 @@ SEAT_MOTION_CAPABILITIES = {
"seat.set_legrest_height": "legrest_height",
"seat.set_legrest_extension": "legrest_extension",
}
SEAT_RELATIVE_PHYSICAL_PARAMETERS = {"distance_mm", "angle_delta_deg"}
SAVE_PRESET_CAPABILITY = "seat.save_posture_preset"
APPLY_PRESET_CAPABILITY = "seat.apply_posture_preset"
HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
@@ -45,6 +47,7 @@ class ModelPlanAdapter:
self,
plan: dict[str, Any],
context: dict[str, Any] | None = None,
vehicle_profile: VehicleProfile | None = None,
) -> dict[str, Any]:
actions: list[dict[str, Any]] = []
domain_actions: list[dict[str, Any]] = []
@@ -73,6 +76,11 @@ class ModelPlanAdapter:
}
allowed_parameters = {item["name"] for item in capability.get("parameters", [])}
unknown_parameters = set(parameters) - allowed_parameters
if (
domain == "seat"
and capability_name in SEAT_MOTION_CAPABILITIES
):
unknown_parameters -= SEAT_RELATIVE_PHYSICAL_PARAMETERS
if unknown_parameters:
ambiguities.append(
{
@@ -162,6 +170,7 @@ class ModelPlanAdapter:
operation_parameters,
ambiguities,
context,
vehicle_profile,
)
if action is not None:
actions.append(action)
@@ -174,7 +183,7 @@ class ModelPlanAdapter:
)
continue
action = self._seat_action(
index, request, parameters, ambiguities, context
index, request, parameters, ambiguities, context, vehicle_profile
)
if action is not None:
actions.append(action)
@@ -367,6 +376,7 @@ class ModelPlanAdapter:
parameters: dict[str, Any],
ambiguities: list[dict[str, str]],
context: dict[str, Any],
vehicle_profile: VehicleProfile | None = None,
) -> dict[str, Any] | None:
target = request.get("target_scope")
if target not in {"driver", "passenger"}:
@@ -381,21 +391,68 @@ class ModelPlanAdapter:
capability = request["capability"]
if capability in SEAT_MOTION_CAPABILITIES:
axis = SEAT_MOTION_CAPABILITIES[capability]
physical_action = ModelPlanAdapter._relative_physical_motion_action(
index,
request,
parameters,
ambiguities,
context,
target,
axis,
vehicle_profile,
)
if physical_action is not None:
return physical_action
angle = parameters.get("angle_deg")
if angle is not None and capability in ANGLE_CALIBRATIONS:
calibration = ANGLE_CALIBRATIONS[capability]
if (
not isinstance(angle, (int, float))
or isinstance(angle, bool)
or not calibration.minimum_deg <= float(angle) <= calibration.maximum_deg
):
if angle is not None:
if not isinstance(angle, (int, float)) or isinstance(angle, bool):
ambiguities.append(
{
"field": f"requests[{index}].parameters.angle_deg",
"question": (
f"{capability} 的夹角必须在 "
f"{calibration.minimum_deg:g}-{calibration.maximum_deg:g} 度之间。"
),
"question": f"{capability} 的夹角必须是数字。",
}
)
return None
profile_motor = (
vehicle_profile.seat_motors.get(axis)
if vehicle_profile is not None
else None
)
if profile_motor is not None and profile_motor.display_angle:
if not profile_motor.contains_display_angle(float(angle)):
display_angle = profile_motor.display_angle
ambiguities.append(
{
"field": f"requests[{index}].parameters.angle_deg",
"question": (
f"{capability} 的夹角必须在 "
f"{display_angle['zero_deg']:g}-{display_angle['max_deg']:g} 度之间。"
),
}
)
return None
position_pct = profile_motor.position_pct_for_display_angle(
float(angle)
)
elif capability in ANGLE_CALIBRATIONS:
calibration = ANGLE_CALIBRATIONS[capability]
if not calibration.minimum_deg <= float(angle) <= calibration.maximum_deg:
ambiguities.append(
{
"field": f"requests[{index}].parameters.angle_deg",
"question": (
f"{capability} 的夹角必须在 "
f"{calibration.minimum_deg:g}-{calibration.maximum_deg:g} 度之间。"
),
}
)
return None
position_pct = angle_to_position_pct(capability, float(angle))
else:
ambiguities.append(
{
"field": f"requests[{index}].parameters.position_pct",
"question": "当前车型未给出该座椅轴的角度标定,需要 0-100 的绝对百分比。",
}
)
return None
@@ -406,7 +463,7 @@ class ModelPlanAdapter:
"action_type": "position",
"target": target,
"axis": axis,
"position_pct": angle_to_position_pct(capability, float(angle)),
"position_pct": position_pct,
"angle_deg": exact_angle,
}
return ModelPlanAdapter._preserve_relative_motion(
@@ -460,6 +517,157 @@ class ModelPlanAdapter:
),
}
@staticmethod
def _relative_physical_motion_action(
index: int,
request: dict[str, Any],
parameters: dict[str, Any],
ambiguities: list[dict[str, str]],
context: dict[str, Any],
target: str,
axis: str,
vehicle_profile: VehicleProfile | None,
) -> dict[str, Any] | None:
physical_parameters = [
name
for name in ("distance_mm", "angle_delta_deg")
if parameters.get(name) is not None
]
if not physical_parameters:
return None
if len(physical_parameters) > 1:
ambiguities.append(
{
"field": f"requests[{index}].parameters",
"question": "单个座椅轴相对移动只能提供一种物理增量。",
}
)
return None
if vehicle_profile is None:
ambiguities.append(
{
"field": f"requests[{index}].parameters.position_pct",
"question": "缺少车型座椅量程,无法把相对物理移动换算为绝对百分比。",
}
)
return None
parameter_name = physical_parameters[0]
delta = parameters[parameter_name]
if (
not isinstance(delta, (int, float))
or isinstance(delta, bool)
or float(delta) <= 0
):
ambiguities.append(
{
"field": f"requests[{index}].parameters.{parameter_name}",
"question": "座椅相对物理移动量必须是正数。",
}
)
return None
motor = vehicle_profile.seat_motors.get(axis)
if motor is None:
ambiguities.append(
{
"field": f"requests[{index}].capability",
"question": f"车型 {vehicle_profile.name} 未配置 {axis} 轴量程。",
}
)
return None
expected_unit = "mm" if parameter_name == "distance_mm" else "degree"
if motor.unit != expected_unit:
ambiguities.append(
{
"field": f"requests[{index}].parameters.{parameter_name}",
"question": (
f"{axis} 轴使用 {motor.unit} 标定,不能用 {expected_unit} 增量换算。"
),
}
)
return None
direction = str(request.get("operation", "set"))
if direction not in {"increase", "decrease"}:
ambiguities.append(
{
"field": f"requests[{index}].operation",
"question": "相对物理移动需要 increase 或 decrease 方向。",
}
)
return None
previous = (
context.get("devices", {})
.get(target, {})
.get("position", {})
.get(axis)
)
if not isinstance(previous, dict):
ambiguities.append(
{
"field": f"requests[{index}].target_scope",
"question": "缺少当前座椅姿态,无法安全执行相对物理移动。",
}
)
return None
current_pct = previous.get("position_pct")
if (
not isinstance(current_pct, (int, float))
or isinstance(current_pct, bool)
):
ambiguities.append(
{
"field": f"requests[{index}].target_scope",
"question": "当前座椅百分比位置无效,无法安全执行相对物理移动。",
}
)
return None
position_pct = vehicle_profile.relative_target_pct(
axis,
current_pct=current_pct,
delta=float(delta),
direction=direction,
)
action: dict[str, Any] = {
"action_type": "position",
"target": target,
"axis": axis,
"position_pct": position_pct,
}
if parameter_name == "angle_delta_deg":
action["angle_deg"] = ModelPlanAdapter._relative_target_angle(
previous,
motor,
position_pct,
float(delta),
direction,
)
return ModelPlanAdapter._preserve_relative_motion(
index, request, action, context, ambiguities
)
@staticmethod
def _relative_target_angle(
previous: dict[str, Any],
motor: Any,
position_pct: int,
delta_deg: float,
direction: str,
) -> int | float:
start_angle = previous.get("angle_deg")
if (
isinstance(start_angle, (int, float))
and not isinstance(start_angle, bool)
):
signed_delta = delta_deg if direction == "increase" else -delta_deg
return _clean_number(float(start_angle) + signed_delta)
display_angle = motor.display_angle_for_pct(position_pct)
if display_angle is None:
return _clean_number(delta_deg)
return _clean_number(display_angle)
@staticmethod
def _preserve_relative_motion(
index: int,
@@ -530,3 +738,7 @@ class ModelPlanAdapter:
)
action["motion_intent"] = motion_intent
return action
def _clean_number(value: float) -> int | float:
return int(value) if float(value).is_integer() else round(float(value), 1)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
"""Vehicle-specific seat calibration profiles."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
@dataclass(frozen=True)
class SeatMotorProfile:
id: str
name: str
unit: str
range: float
reference_zero: str
positive_direction: str
hall_total_pulses: int
display_angle: dict[str, Any] | None = None
def delta_to_pct(self, delta: float) -> float:
if self.range <= 0:
raise ValueError(f"motor {self.id} range must be positive")
return float(delta) / self.range * 100
def display_angle_for_pct(self, position_pct: int) -> float | None:
if not self.display_angle:
return None
zero = float(self.display_angle["zero_deg"])
maximum = float(self.display_angle["max_deg"])
return zero + (maximum - zero) * _clamp_pct(position_pct) / 100
def position_pct_for_display_angle(self, angle_deg: int | float) -> int:
if not self.display_angle:
raise ValueError(f"motor {self.id} does not define a display angle range")
zero = float(self.display_angle["zero_deg"])
maximum = float(self.display_angle["max_deg"])
if maximum <= zero:
raise ValueError(f"motor {self.id} display angle range must be positive")
normalized = (float(angle_deg) - zero) / (maximum - zero)
return _clamp_pct(round(normalized * 100))
def contains_display_angle(self, angle_deg: int | float) -> bool:
if not self.display_angle:
return False
zero = float(self.display_angle["zero_deg"])
maximum = float(self.display_angle["max_deg"])
return zero <= float(angle_deg) <= maximum
def public_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"id": self.id,
"name": self.name,
"unit": self.unit,
"range": _public_number(self.range),
"reference_zero": self.reference_zero,
"positive_direction": self.positive_direction,
"hall_total_pulses": self.hall_total_pulses,
}
if self.display_angle is not None:
payload["display_angle"] = dict(self.display_angle)
return payload
@dataclass(frozen=True)
class VehicleProfile:
id: str
name: str
seat_motors: dict[str, SeatMotorProfile]
def relative_target_pct(
self,
axis: str,
*,
current_pct: int | float,
delta: int | float,
direction: str,
) -> int:
motor = self.seat_motors[axis]
delta_pct = motor.delta_to_pct(float(delta))
if direction == "decrease":
delta_pct = -delta_pct
elif direction != "increase":
raise ValueError(f"unsupported relative direction: {direction}")
return _clamp_pct(round(float(current_pct) + delta_pct))
def display_angle_for_pct(self, axis: str, position_pct: int) -> float | None:
return self.seat_motors[axis].display_angle_for_pct(position_pct)
def public_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"seat": {
"motors": {
key: motor.public_dict()
for key, motor in self.seat_motors.items()
}
},
}
class VehicleProfileRegistry:
def __init__(self, profile_root: Path) -> None:
self.profile_root = profile_root
self._profiles = self._load_profiles(profile_root)
def get(self, vehicle_id: str | None) -> VehicleProfile:
resolved = vehicle_id or self.default().id
try:
return self._profiles[resolved]
except KeyError as exc:
raise ValueError(f"unknown vehicle profile: {resolved}") from exc
def default(self) -> VehicleProfile:
if "denza_n7" in self._profiles:
return self._profiles["denza_n7"]
try:
return next(iter(self._profiles.values()))
except StopIteration as exc:
raise ValueError(f"no vehicle profiles found in {self.profile_root}") from exc
def public_profiles(self) -> list[dict[str, Any]]:
return [profile.public_dict() for profile in self._profiles.values()]
@staticmethod
def _load_profiles(profile_root: Path) -> dict[str, VehicleProfile]:
profiles: dict[str, VehicleProfile] = {}
for path in sorted(profile_root.glob("*.yaml")):
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
profile = _parse_vehicle_profile(data)
profiles[profile.id] = profile
return profiles
def _parse_vehicle_profile(data: dict[str, Any]) -> VehicleProfile:
seat = data.get("seat") or {}
motors = seat.get("motors") or {}
seat_motors = {
str(axis): SeatMotorProfile(
id=str(axis),
name=str(payload["name"]),
unit=str(payload["unit"]),
range=float(payload["range"]),
reference_zero=str(payload["reference_zero"]),
positive_direction=str(payload["positive_direction"]),
hall_total_pulses=int(payload["hall_total_pulses"]),
display_angle=payload.get("display_angle"),
)
for axis, payload in motors.items()
}
return VehicleProfile(
id=str(data["id"]),
name=str(data["name"]),
seat_motors=seat_motors,
)
def _clamp_pct(value: int | float) -> int:
return max(0, min(100, int(value)))
def _public_number(value: float) -> int | float:
return int(value) if value.is_integer() else value
@@ -129,6 +129,13 @@ and current state. The absence of a named preset is never, by itself, an ambigui
assumptions in decision_summary. Safety validation and hardware eligibility are downstream concerns;
do not withhold a dry-run candidate merely because a capability is marked high risk.
Runtime context may include mode_profiles. These are trusted long-term mode configuration data,
not new capabilities. If the user names one of those profiles, the deterministic runtime will inject
that profile's operations after planning. In that case, return only the user's explicit additional
goals that are not already represented by the named profile, and mention the named profile in
decision_summary. If the user names no existing profile, synthesize a temporary plan from atomic
capabilities as usual.
Return every understood goal in requests using an exact capability name from the registry. Use
target_scope and operation to preserve the user's intent. Put concrete capability parameters in the
parameters list when they are known or defensibly inferred. Do not drop catalog_only requests; the
+10 -3
View File
@@ -145,9 +145,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
str(payload.get("provider", "auto")),
str(payload["conversation_id"]) if payload.get("conversation_id") else None,
str(payload.get("execution_mode", "dry_run")),
str(payload["approval_token"])
if payload.get("approval_token")
else None,
approval_token=(
str(payload["approval_token"])
if payload.get("approval_token")
else None
),
vehicle_id=(
str(payload["vehicle_id"])
if payload.get("vehicle_id")
else None
),
)
self._json(result, HTTPStatus.CREATED)
except HardwareSafetyError as exc:
+18
View File
@@ -108,6 +108,8 @@ class SeatWebApiTests(unittest.TestCase):
self.assertIn('data-twin-function="heating"', html)
self.assertIn('class="twin-level-meter"', html)
self.assertIn('id="presetIndicator"', html)
self.assertIn('id="vehicleSelect"', html)
self.assertIn('id="saveModeToggle"', html)
self.assertIn('data-domain-panel="climate"', html)
self.assertIn('data-domain-panel="roof"', html)
self.assertIn('data-domain-panel="ambient_light"', html)
@@ -118,6 +120,20 @@ class SeatWebApiTests(unittest.TestCase):
self.assertGreaterEqual(runtime["matrix"]["messages"], 25)
self.assertGreaterEqual(runtime["matrix"]["signals"], 128)
self.assertEqual(runtime["motion_position_range_pct"], [0, 100])
self.assertEqual(runtime["default_vehicle_id"], "denza_n7")
self.assertEqual(runtime["vehicle"]["name"], "腾势 N7")
self.assertIn(
"denza_n7",
{vehicle["id"] for vehicle in runtime["vehicles"]},
)
self.assertIn(
"zero_gravity",
{mode["id"] for mode in runtime["modes"]},
)
self.assertEqual(
runtime["vehicle"]["seat"]["motors"]["longitudinal"]["range"],
215,
)
capability_names = {item["name"] for item in runtime["capabilities"]}
self.assertIn("seat.save_posture_preset", capability_names)
self.assertIn("seat.apply_posture_preset", capability_names)
@@ -135,6 +151,7 @@ class SeatWebApiTests(unittest.TestCase):
"utterance": "主驾有点热,有点闷",
"provider": "local",
"conversation_id": "conv-e2e-browser",
"vehicle_id": "denza_n7",
}
).encode("utf-8"),
)
@@ -146,6 +163,7 @@ class SeatWebApiTests(unittest.TestCase):
self.assertEqual(result["command_plan"]["kind"], "CommandPlan")
self.assertEqual(result["execution_report"]["spec"]["mode"], "dry_run")
self.assertEqual(result["conversation_id"], "conv-e2e-browser")
self.assertEqual(result["vehicle"]["id"], "denza_n7")
self.assertEqual(result["actuator_state"]["driver"]["ventilation"]["level"], 1)
def test_hardware_request_is_rejected_when_server_gate_is_locked(self) -> None:
@@ -6,6 +6,7 @@ from pathlib import Path
from unittest.mock import patch
from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
@@ -219,6 +220,260 @@ class MultiDomainServiceTests(unittest.TestCase):
self.assertEqual(result["domain_state"]["roof"]["transmittance_pct"], 20)
self.assertEqual(result["conversation_turn"], 2)
def test_named_mode_profile_executes_without_model_expansion(self) -> None:
model_output = {
"detected_domains": ["seat"],
"requests": [],
"ambiguities": [],
"decision_summary": ["用户要求调用已有零重力模式。"],
"confidence": 0.94,
}
with patch.object(self.service.openai, "interpret", return_value=model_output):
result = self.service.process(
"副驾驶进入零重力模式",
"openai",
"conv-zero-gravity-mode",
vehicle_id="denza_n7",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["matched_modes"][0]["id"], "zero_gravity")
operations = result["function_config"]["spec"]["operations"]
by_capability = {item["capability"]: item for item in operations}
self.assertEqual(
by_capability["seat.set_longitudinal_position"]["parameters"],
{"position_pct": 0},
)
self.assertEqual(
by_capability["seat.set_cushion_tilt"]["parameters"],
{"position_pct": 100},
)
self.assertEqual(
by_capability["seat.set_backrest_recline"]["parameters"],
{"position_pct": 81, "angle_deg": 150},
)
self.assertEqual(
by_capability["seat.set_legrest_height"]["parameters"],
{"position_pct": 87, "angle_deg": 163},
)
def test_named_mode_profile_combines_with_temporary_roof_request(self) -> None:
model_output = {
"detected_domains": ["roof"],
"requests": [
{
"domain": "roof",
"capability": "roof.set_transmittance",
"target_scope": "vehicle",
"operation": "set",
"parameters": [{"name": "transmittance_pct", "value": 20}],
"reason": "用户同时要求天幕调暗。",
}
],
"ambiguities": [],
"decision_summary": ["零重力之外还需设置天幕。"],
"confidence": 0.96,
}
with patch.object(self.service.openai, "interpret", return_value=model_output):
result = self.service.process(
"副驾零重力模式,天幕透光率调到20%",
"openai",
"conv-mode-plus-roof",
vehicle_id="denza_n7",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(set(result["function_configs"]), {"seat", "roof"})
self.assertEqual(result["scene_config"]["kind"], "SceneConfig")
self.assertEqual(result["domain_state"]["roof"]["transmittance_pct"], 20)
self.assertIn(
"seat.set_backrest_recline",
{
item["capability"]
for item in result["function_configs"]["seat"]["spec"]["operations"]
},
)
def test_reference_mode_library_modes_execute_for_denza(self) -> None:
model_output = {
"detected_domains": ["seat"],
"requests": [],
"ambiguities": [],
"decision_summary": ["用户要求调用已有长期模式。"],
"confidence": 0.95,
}
utterances = [
"副驾驶进入零重力模式",
"副驾驶进入巡航沙发模式",
"副驾驶进入休闲躺椅模式",
"副驾驶进入小憩模式",
"副驾驶进入轻松进出模式",
"副驾驶进入宝宝午睡模式",
"副驾驶进入办公模式",
"副驾驶进入冥想空间模式",
"副驾驶进入电感沙滩椅",
"副驾驶进入女王副驾模式",
"副驾驶进入小腿解压悬浮模式",
]
with patch.object(self.service.openai, "interpret", return_value=model_output):
for index, utterance in enumerate(utterances):
with self.subTest(utterance=utterance):
result = self.service.process(
utterance,
"openai",
f"conv-reference-mode-{index}",
vehicle_id="denza_n7",
)
self.assertEqual(result["status"], "succeeded")
self.assertTrue(result["matched_modes"])
self.assertTrue(
result["function_config"] or result["function_configs"]
)
def test_save_recent_successful_actions_as_long_term_mode_profile(self) -> None:
self.service.mode_profiles = ModeProfileRegistry(
self.service.artifact_root / "modes"
)
model_output = {
"detected_domains": ["roof"],
"requests": [
{
"domain": "roof",
"capability": "roof.set_transmittance",
"target_scope": "vehicle",
"operation": "set",
"parameters": [{"name": "transmittance_pct", "value": 35}],
"reason": "保存前先形成一个可复用动作。",
}
],
"ambiguities": [],
"decision_summary": ["天幕透光率设为35%。"],
"confidence": 0.96,
}
with patch.object(self.service.openai, "interpret", return_value=model_output):
first = self.service.process(
"天幕透光率调到35%",
"openai",
"conv-save-mode-profile",
vehicle_id="denza_n7",
)
saved = self.service.process(
"这个模式不错,固化为露营模式",
"local",
"conv-save-mode-profile",
vehicle_id="denza_n7",
)
self.assertEqual(first["status"], "succeeded")
self.assertEqual(saved["status"], "succeeded")
self.assertIsNone(saved["command_plan"])
self.assertEqual(saved["mode_profile"]["name"], "露营模式")
telemetry = saved["execution_report"]["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["operation"], "save_mode_profile")
self.assertFalse(telemetry["can_command_emitted"])
registry = ModeProfileRegistry(self.service.artifact_root / "modes")
matched = registry.match_text("进入露营模式", vehicle_id="denza_n7")
requests = registry.requests_for_profiles(matched)
self.assertEqual(requests[0]["capability"], "roof.set_transmittance")
self.assertEqual(
requests[0]["parameters"],
[{"name": "transmittance_pct", "value": 35}],
)
self.assertEqual(
registry.match_text("进入露营模式", vehicle_id="other_vehicle"),
[],
)
def test_update_and_restore_long_term_mode_profile_are_control_plane_only(self) -> None:
self.service.mode_profiles = ModeProfileRegistry(
self.service.artifact_root / "modes"
)
profile = self.service.mode_profiles.save_mode(
"露营模式",
[
{
"domain": "roof",
"capability": "roof.set_transmittance",
"target_scope": "vehicle",
"operation": "set",
"parameters": {"transmittance_pct": 20},
"reason": "原始天幕目标。",
}
],
vehicle_id="denza_n7",
)
model_output = {
"detected_domains": ["roof"],
"requests": [
{
"domain": "roof",
"capability": "roof.set_transmittance",
"target_scope": "vehicle",
"operation": "set",
"parameters": [{"name": "transmittance_pct", "value": 50}],
"reason": "用户修改长期模式天幕目标。",
}
],
"ambiguities": [],
"decision_summary": ["修改露营模式天幕目标。"],
"confidence": 0.96,
}
with patch.object(self.service.openai, "interpret", return_value=model_output):
updated = self.service.process(
"修改露营模式,天幕透光率改到50%",
"openai",
"conv-update-mode-profile",
execution_mode="hardware",
vehicle_id="denza_n7",
)
self.assertEqual(updated["status"], "succeeded")
self.assertIsNone(updated["command_plan"])
update_telemetry = updated["execution_report"]["spec"]["results"][0][
"telemetry"
]
self.assertEqual(update_telemetry["operation"], "update_mode_profile")
self.assertFalse(update_telemetry["can_command_emitted"])
self.assertTrue(Path(profile.source).with_suffix(".yaml.initial.bak").is_file())
registry = ModeProfileRegistry(self.service.artifact_root / "modes")
requests = registry.requests_for_profiles(
registry.match_text("进入露营模式", vehicle_id="denza_n7")
)
self.assertEqual(
requests[0]["parameters"],
[{"name": "transmittance_pct", "value": 50}],
)
restored = self.service.process(
"恢复露营模式初始配置",
"local",
"conv-update-mode-profile",
execution_mode="hardware",
vehicle_id="denza_n7",
)
self.assertEqual(restored["status"], "succeeded")
self.assertIsNone(restored["command_plan"])
restore_telemetry = restored["execution_report"]["spec"]["results"][0][
"telemetry"
]
self.assertEqual(restore_telemetry["operation"], "restore_mode_profile")
self.assertFalse(restore_telemetry["can_command_emitted"])
restored_registry = ModeProfileRegistry(self.service.artifact_root / "modes")
restored_requests = restored_registry.requests_for_profiles(
restored_registry.match_text("进入露营模式", vehicle_id="denza_n7")
)
self.assertEqual(
restored_requests[0]["parameters"],
[{"name": "transmittance_pct", "value": 20}],
)
if __name__ == "__main__":
unittest.main()
+265
View File
@@ -0,0 +1,265 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
import yaml
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.seat_service import REPOSITORY_ROOT
class ModeProfileRegistryTests(unittest.TestCase):
def test_zero_gravity_profile_matches_alias_and_builds_requests(self) -> None:
registry = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes")
matched = registry.match_text("副驾驶进入零重力模式", vehicle_id="denza_n7")
self.assertEqual([profile.id for profile in matched], ["zero_gravity"])
self.assertEqual(matched[0].vehicle_ids, ("denza_n7",))
requests = registry.requests_for_profiles(matched)
self.assertGreaterEqual(len(requests), 4)
self.assertEqual(requests[0]["domain"], "seat")
self.assertEqual(requests[0]["capability"], "seat.set_longitudinal_position")
self.assertEqual(requests[0]["target_scope"], "passenger")
self.assertIn(
{"name": "position_pct", "value": 0},
requests[0]["parameters"],
)
by_capability = {request["capability"]: request for request in requests}
self.assertEqual(
by_capability["seat.set_cushion_tilt"]["parameters"],
[{"name": "position_pct", "value": 100}],
)
self.assertEqual(
by_capability["seat.set_backrest_recline"]["parameters"],
[
{"name": "position_pct", "value": 81},
{"name": "angle_deg", "value": 150},
],
)
self.assertEqual(
by_capability["seat.set_legrest_height"]["parameters"],
[
{"name": "position_pct", "value": 87},
{"name": "angle_deg", "value": 163},
],
)
public = registry.public_modes()
self.assertIn("零重力模式", {item["name"] for item in public})
self.assertNotIn(
"zero_gravity",
{profile.id for profile in registry.match_text("副驾驶进入零重力模式", vehicle_id="other_vehicle")},
)
def test_reference_modes_are_denza_specific(self) -> None:
registry = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes")
expected_ids = {
"zero_gravity",
"cruise_sofa",
"leisure_lounger",
"nap",
"easy_entry_exit",
"baby_nap",
"office",
"meditation_space",
"electric_beach_chair",
"queen_passenger_driving",
"calf_decompression_float",
}
denza_modes = registry.public_modes(vehicle_id="denza_n7")
denza_ids = {mode["id"] for mode in denza_modes}
self.assertTrue(expected_ids.issubset(denza_ids))
self.assertTrue(
expected_ids.isdisjoint(
{mode["id"] for mode in registry.public_modes(vehicle_id="other_vehicle")}
)
)
self.assertEqual(
registry.match_text("进入办公模式", vehicle_id="denza_n7")[0].id,
"office",
)
self.assertEqual(
registry.match_text("进入小腿解压悬浮模式", vehicle_id="denza_n7")[0].id,
"calf_decompression_float",
)
def test_common_modes_are_visible_with_vehicle_specific_overrides(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
common = root / "common"
denza = root / "vehicles" / "denza_n7"
other = root / "vehicles" / "other_vehicle"
common.mkdir(parents=True)
denza.mkdir(parents=True)
other.mkdir(parents=True)
(common / "relax.yaml").write_text(
"""
id: relax
name: 放松模式
aliases:
- relax
description: 公共放松模式。
operations:
- domain: roof
capability: roof.set_transmittance
target_scope: vehicle
operation: set
parameters:
transmittance_pct: 45
""".strip()
+ "\n",
encoding="utf-8",
)
(denza / "relax.yaml").write_text(
"""
id: relax
name: N7 放松模式
aliases:
- relax
description: N7 专属放松模式。
scope:
vehicle_ids:
- denza_n7
operations:
- domain: roof
capability: roof.set_transmittance
target_scope: vehicle
operation: set
parameters:
transmittance_pct: 20
""".strip()
+ "\n",
encoding="utf-8",
)
(other / "nap.yaml").write_text(
"""
id: nap
name: 午休模式
description: 其他车型专属午休模式。
operations:
- domain: roof
capability: roof.set_transmittance
target_scope: vehicle
operation: set
parameters:
transmittance_pct: 10
""".strip()
+ "\n",
encoding="utf-8",
)
registry = ModeProfileRegistry(root)
denza_matches = registry.match_text("进入放松模式", vehicle_id="denza_n7")
other_matches = registry.match_text("进入放松模式", vehicle_id="other_vehicle")
self.assertEqual([profile.name for profile in denza_matches], ["N7 放松模式"])
self.assertEqual([profile.name for profile in other_matches], ["放松模式"])
self.assertEqual(
{profile.id for profile in registry.match_text("进入午休模式", vehicle_id="denza_n7")},
set(),
)
self.assertEqual(
{mode["id"] for mode in registry.public_modes(vehicle_id="denza_n7")},
{"relax"},
)
def test_save_mode_profile_persists_yaml_and_reloads(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
registry = ModeProfileRegistry(root)
profile = registry.save_mode(
"露营模式",
[
{
"domain": "roof",
"capability": "roof.set_transmittance",
"target_scope": "vehicle",
"operation": "set",
"parameters": {"transmittance_pct": 20},
"reason": "保存最近一次天幕目标。",
}
],
vehicle_id="denza_n7",
)
saved_path = Path(profile.source)
self.assertTrue(saved_path.is_file())
self.assertEqual(
saved_path.relative_to(root).parts[:3],
("vehicles", "denza_n7", "user"),
)
document = yaml.safe_load(saved_path.read_text(encoding="utf-8"))
self.assertEqual(document["name"], "露营模式")
self.assertEqual(document["scope"]["vehicle_ids"], ["denza_n7"])
self.assertEqual(document["operations"][0]["domain"], "roof")
reloaded = ModeProfileRegistry(root)
matched = reloaded.match_text("进入露营模式", vehicle_id="denza_n7")
self.assertEqual([item.name for item in matched], ["露营模式"])
self.assertEqual(
reloaded.match_text("进入露营模式", vehicle_id="other_vehicle"),
[],
)
requests = reloaded.requests_for_profiles(matched)
self.assertEqual(requests[0]["capability"], "roof.set_transmittance")
self.assertEqual(
requests[0]["parameters"],
[{"name": "transmittance_pct", "value": 20}],
)
def test_update_mode_profile_backs_up_initial_yaml_and_restores(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
registry = ModeProfileRegistry(root)
profile = registry.save_mode(
"露营模式",
[
{
"domain": "roof",
"capability": "roof.set_transmittance",
"target_scope": "vehicle",
"operation": "set",
"parameters": {"transmittance_pct": 20},
"reason": "原始天幕目标。",
}
],
vehicle_id="denza_n7",
)
updated = registry.update_mode(
profile,
[
{
"domain": "roof",
"capability": "roof.set_transmittance",
"target_scope": "vehicle",
"operation": "set",
"parameters": {"transmittance_pct": 50},
"reason": "用户修改天幕目标。",
}
],
)
self.assertTrue(Path(profile.source).with_suffix(".yaml.initial.bak").is_file())
requests = registry.requests_for_profiles([updated])
self.assertEqual(
requests[0]["parameters"],
[{"name": "transmittance_pct", "value": 50}],
)
restored = registry.restore_initial_mode(updated)
restored_requests = registry.requests_for_profiles([restored])
self.assertEqual(
restored_requests[0]["parameters"],
[{"name": "transmittance_pct", "value": 20}],
)
if __name__ == "__main__":
unittest.main()
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import unittest
from pathlib import Path
from cognitive_agent.application.vehicle_profile import VehicleProfileRegistry
ROOT = Path(__file__).resolve().parents[2]
class VehicleProfileTests(unittest.TestCase):
def setUp(self) -> None:
self.registry = VehicleProfileRegistry(ROOT / "config" / "vehicles")
def test_loads_denza_n7_seat_motor_calibration(self) -> None:
profile = self.registry.get("denza_n7")
self.assertEqual(profile.id, "denza_n7")
self.assertEqual(profile.name, "腾势 N7")
self.assertEqual(set(profile.seat_motors), {
"longitudinal",
"cushion_tilt",
"backrest_recline",
"legrest_height",
})
self.assertEqual(profile.seat_motors["longitudinal"].range, 215)
self.assertEqual(profile.seat_motors["longitudinal"].hall_total_pulses, 480)
self.assertEqual(profile.seat_motors["longitudinal"].reference_zero, "rearmost")
self.assertEqual(profile.seat_motors["backrest_recline"].range, 68)
self.assertEqual(profile.seat_motors["backrest_recline"].hall_total_pulses, 917)
self.assertEqual(profile.seat_motors["legrest_height"].range, 78)
self.assertEqual(profile.seat_motors["legrest_height"].hall_total_pulses, 647)
def test_converts_relative_physical_delta_to_absolute_percent(self) -> None:
profile = self.registry.get("denza_n7")
self.assertEqual(
profile.relative_target_pct("longitudinal", current_pct=50, delta=50, direction="increase"),
73,
)
self.assertEqual(
profile.relative_target_pct("backrest_recline", current_pct=20, delta=10, direction="increase"),
35,
)
self.assertEqual(
profile.relative_target_pct("legrest_height", current_pct=10, delta=10, direction="decrease"),
0,
)
def test_exposes_display_angle_ranges_for_frontend(self) -> None:
profile = self.registry.get("denza_n7")
public = profile.public_dict()
backrest = public["seat"]["motors"]["backrest_recline"]
legrest = public["seat"]["motors"]["legrest_height"]
self.assertEqual(backrest["display_angle"], {
"kind": "included_angle",
"zero_deg": 95,
"max_deg": 163,
"provisional": True,
})
self.assertEqual(legrest["display_angle"], {
"kind": "included_angle",
"zero_deg": 95,
"max_deg": 173,
"provisional": True,
})
if __name__ == "__main__":
unittest.main()
+303
View File
@@ -0,0 +1,303 @@
> 长期库处理说明(2026-07-30):本文件中所有模式都包含腾势 N7 座椅四电机姿态参数,因此本次均判定为 N7 车型专属模式,写入 `config/modes/vehicles/denza_n7/`;公共库本次未新增。未标注的座椅、温度、风量、内外循环、天幕遮阳帘、整车氛围灯颜色/亮度等项目已按现有能力落库。标注 `【暂不支持】` 或 `【部分支持】` 的项目未完整落库。
# 零重力模式
## 座椅
1、水平电机:0mm
2、坐盆电机:100%
3、靠背电机:55°
4、 腿托电机:68°
# 巡航沙发模式
## 座椅
1、水平电机:105mm
2、坐盆电机:30%
3、靠背电机:30°
4、 腿托电机:38°
# 休闲躺椅模式
## 座椅
1、水平电机:105mm
2、坐盆电机:30%
3、靠背电机:45°
4、 腿托电机:55°
## 空调
1. 二排右侧专属温区自动调至 24–26℃舒适区间
2. 出风口自动切换至吹脚 + 吹腰模式,风量中档【部分支持:现有空调能力只支持 face/feet 出风,已落库吹脚;腰部出风没有 CAN 能力。】
3. 自动切换外循环,避免密闭缺氧;冬季自动开启座椅 + 腿托加热,夏季自动开启座椅通风【部分支持:外循环已落库;季节条件判断、腿托加热、按季节自动切换座椅加热/通风暂不支持。】
## 天幕
1. 全景天幕电动遮阳帘完全关闭
2. 二排右侧侧窗电动遮阳帘自动升起,隔绝强光【暂不支持:现有 roof 能力只覆盖全景天幕遮阳帘/调光天幕,没有侧窗遮阳帘 CAN 能力。】
3. 后排隐私玻璃配合遮光,降低光线刺激便于休息【暂不支持:没有后排隐私玻璃/侧窗调光 CAN 能力。】
## 音响
1. 后排娱乐屏自动调暗亮度,或切换至静态舒缓画面【暂不支持:当前没有屏幕亮度/壁纸/娱乐屏控制能力。】
2. 整车音响音量自动降低,屏蔽尖锐音效;可自动切换白噪音 / 静谧声场【暂不支持:当前没有音响音量、音源、声场或白噪音 CAN 能力。】
3. 头枕音响切换柔和低频模式,减少震动扰眠【暂不支持:当前没有头枕音响控制能力。】
## 氛围灯
1. 全车阅读灯全部熄灭【暂不支持:当前 ambient_light 只覆盖整车氛围灯,不覆盖阅读灯。】
2. 内饰氛围灯切换低亮度暖光(色温 3000K),亮度降至 20%,不刺眼
3. 车门、扶手处辅助氛围灯减弱【暂不支持:当前氛围灯能力是整车级颜色/亮度/模式,没有门板、扶手分区控制。】
# 小憩模式
## 座椅
1、水平电机:215mm
2、坐盆电机:0
3、靠背电机:0
4、 腿托电机:0
## 空调
1. 自动设定 22~24℃恒温,低风静音档位
2. 出风口自动调至水平上吹,避免直吹人体【暂不支持:现有空调出风模式只有 face/feet,不能表达水平上吹或避免直吹。】
3. 内外循环自动切换内循环,兼顾静音与保温;电池电量充足时关闭增程器,杜绝发动机震动噪音理想汽车【部分支持:内循环已落库;电池电量判断、增程器关闭、发动机震动噪音控制暂不支持。】
4. 带冰箱车型:冰箱维持低功耗恒温运行【暂不支持:当前没有车载冰箱控制能力。】
## 天幕
1. 全车车窗:自动完全升窗关闭,杜绝风噪、漏光、蚊虫【暂不支持:当前没有车窗升降 CAN 能力。】
2. 全景天幕电动遮阳帘:前后天幕遮阳帘全关
3. 二排侧窗电动隐私遮阳帘:全部关闭,完全遮光打造暗室环境理想汽车【暂不支持:当前没有二排侧窗隐私遮阳帘控制能力。】
4. 门锁执行电机:车辆自动全车落锁;临时屏蔽靠近解锁、无钥匙进入,防止外人开门打扰休息【暂不支持:当前没有门锁、靠近解锁或无钥匙进入策略控制能力。】
## 音响
1. 中控仪表、后排娱乐屏:屏幕切换星空静谧壁纸,屏幕大幅降亮度,减少光线刺激理想汽车【暂不支持:当前没有仪表/屏幕壁纸或亮度控制能力。】
2. 音响系统:自动开启潮汐 / 自然白噪音(海浪、森林、雨声),音量低噪柔和;导航播报、电话铃声临时静音(勿扰逻辑)【暂不支持:当前没有音响音源/音量、导航播报、电话勿扰控制能力。】
3. 内置定时闹钟计时模块:支持 15/30/60 分钟定时,时间到自动播放舒缓音乐唤醒用户【暂不支持:当前 mode profile 不支持定时器、闹钟或到时自动执行后续动作。】
## 氛围灯
车内全部阅读灯、门板氛围灯、中控氛围灯:亮度降至最低 / 直接关闭【部分支持:整车氛围灯关闭已落库;阅读灯、门板/中控分区亮度暂不支持。】
# 轻松进出模式
## 上下车门:
### 座椅
1、水平电机:0mm
2、坐盆电机:0%
3、靠背电机:0°
4、腿托电机:0°
## 关门(坐好):【暂不支持:当前长期模式库是静态动作集合,没有门状态触发和关门后自动恢复第二阶段;已落库“上下车门”进出姿态。】
### 座椅
1、水平电机:105mm
2、坐盆电机:0%
3、靠背电机:0°
4、 腿托电机:0°
# 宝宝午睡模式
## 座椅
1、水平电机:40mm
2、坐盆电机:30%
3、靠背电机:28°
4、腿托电机:0°
## 空调
1. 全车恒温锁定24℃,六温区分区,后排独立控温不跟随前排;【部分支持:全车 24℃ 已落库;六温区独立策略和后排不跟随前排的联动策略暂不支持。】
2. 前排出风口上调水平送风,关闭后排出风直吹模式,避免冷风刺激宝宝;【暂不支持:现有空调出风模式只有 face/feet,不能表达水平送风、关闭后排直吹或防直吹策略。】
3. 风量强制最低静音 1 档,自动切换内循环,开启森林氧吧空气净化;【部分支持:风量 1 档和内循环已落库;森林氧吧/空气净化暂不支持。】
## 天幕
1. 全车车窗自动完全升窗;后排电子儿童锁一键上锁,内外拉手失效,退出模式自动解除;【暂不支持:当前没有车窗、儿童锁、门把手失效或退出模式自动解除控制能力。】
2. 全景天幕遮阳帘90% 关闭,保留微弱漫反射柔光,避免全黑造成宝宝恐惧;后排侧隐私帘半关闭;【部分支持:全景天幕遮阳帘 90% 关闭已落库;后排侧隐私帘半关闭暂不支持。】
## 音响
1. 中控、后排娱乐屏大幅降亮度,切换纯色静谧壁纸,关闭动画强光;【暂不支持:当前没有屏幕亮度、壁纸或动画控制能力。】
2. 全车多媒体静音、导航 / 来电播报屏蔽;仅主驾头枕音响保留极低音量预警;【暂不支持:当前没有多媒体静音、导航/来电播报屏蔽或头枕音响控制能力。】
3. 自动播放低音量雨声 / 森林潮汐白噪音,隔绝路噪;后排扬声器音量锁死 60 分贝以内,保护儿童听力;【暂不支持:当前没有白噪音音源、后排扬声器音量锁定或分贝限制能力。】
## 氛围灯
1. 车内阅读灯全部熄灭;氛围灯亮度降至 5% 极暗暖白光,无频闪;【部分支持:5% 暖白氛围灯已落库;阅读灯关闭和无频闪策略暂不支持。】
2. 外部日行灯、迎宾解锁灯光全部关闭,屏蔽夜间强光刺激;【暂不支持:当前没有外部日行灯或迎宾解锁灯控制能力。】
# 办公模式
## 座椅
1、水平电机:60mm
2、坐盆电机:22%
3、靠背电机:15°
4、 腿托电机:15°
## 空调
1. 座舱恒温锁定24.5℃,双区分区独立控温,办公座位专属送风;【部分支持:副驾 24.5℃ 已落库;双区独立策略和办公座位专属送风策略仅能部分通过 passenger 目标表达。】
2. 出风口自动上调至水平上吹,完全关闭正面直吹头部、手部风道,避免冷风刺激导致打字手部僵硬;【部分支持:已用 passenger face 近似表达上吹;水平上吹、关闭头部/手部风道和防直吹策略暂不支持。】
3. 风量中档静音档(区别休憩 1 档极低风量),兼顾空气循环与车内收音降噪;自动切换内循环,开启 PM2.5 / 负离子净化;【部分支持:风量中低档和内循环已落库;收音降噪、PM2.5/负离子净化暂不支持。】
## 天幕
1. 车车窗自动完全升窗,隔绝路噪、路人视线,保护办公隐私;【暂不支持:当前没有车窗升降控制能力。】
2. 全景天幕遮阳帘70% 半关闭(核心差异:小憩 / 冥想 100% 全黑遮光);保留柔和漫反射自然光,防止长时间全黑屏幕反光伤眼,兼顾专注与护眼;后排侧隐私帘半关;【部分支持:全景天幕遮阳帘 70% 半关闭已落库;后排侧隐私帘半关暂不支持。】
3. 全车车门自动落锁,临时屏蔽无钥匙靠近解锁、迎宾灯光,避免外人开门打断会议;【暂不支持:当前没有门锁、无钥匙进入或迎宾灯控制能力。】
## 音响
1. 全车音响进入会议静音优先逻辑
- 导航、来电多媒体铃声全部静音勿扰;【暂不支持:当前没有导航、来电或多媒体勿扰控制能力。】
- 关闭海浪 / 森林白噪音(休憩模式标配),仅保留极低音量主动降噪抵消路噪;【暂不支持:当前没有白噪音或主动降噪控制能力。】
- 头枕独立麦克风自动开启降噪算法,适配线上腾讯会议、飞书、钉钉语音通话;【暂不支持:当前没有头枕麦克风、会议应用或降噪算法控制能力。】
## 氛围灯
屏幕亮度提高、冷白光氛围灯【部分支持:冷白光氛围灯已落库;屏幕亮度提高暂不支持。】
# 冥想空间模式
## 座椅
1、水平电机:0mm
2、坐盆电机:44%
3、靠背电机:30°
4、腿托电机:42°
## 空调
1. 多温区空调压缩机、鼓风机:恒温 22–24℃、低柔风量【部分支持:恒温 23℃ 和低风量已落库;多温区压缩机/鼓风机细分控制暂不支持。】
2. 空调风门执行电机:自动切换内循环,隔绝外界异味
## 天幕
1. 四门车窗升降电机:默认自动升窗密闭(新版 OTA 可自定义关闭)【暂不支持:当前没有四门车窗升降或 OTA 自定义策略控制能力。】
2. 全景天幕遮阳帘电机:完全闭合遮光
3. 车门锁块、电吸门执行机构:保持闭锁强化隔音【暂不支持:当前没有门锁块或电吸门执行机构控制能力。】
## 音响
1. 全车杜比全景声扬声器阵列(含头枕音响单元)【暂不支持:当前没有扬声器阵列或头枕音响控制能力。】
2. 车载 DSP 功放、主动降噪 ANC 模块:反向抵消风噪胎噪【暂不支持:当前没有 DSP 功放或 ANC 控制能力。】
3. 中控大屏、副驾娱乐屏、后排悬浮屏:自动息屏 / 低亮度极简界面【暂不支持:当前没有屏幕息屏、亮度或界面控制能力。】
4. 车载音源解码模块:加载自然白噪音 / 冥想纯音循环播放【暂不支持:当前没有车载音源或白噪音播放控制能力。】
## 氛围灯
中控大屏:进入冥想主题壁纸(森林 / 海浪 / 云雾低饱和画面),屏幕亮度调低,长时间无操作后进一步暗屏;【暂不支持:当前没有中控屏壁纸、亮度或暗屏策略控制能力。】
自动大幅降低亮度(仅日常亮度 30% 左右),杜绝强光刺眼,整体柔光包裹座舱,无刺眼高光;
绑定 5 大冥想主题自动切换,仅做极缓慢线性渐变,无快速变色、频闪,降低视觉刺激;【部分支持:矩阵定义的 meditation 模式和 30% 柔光已落库;5 大主题、缓慢渐变和无频闪策略暂不支持。】
全车灯带同步统一色调:仪表台贯穿灯带、四门门板灯带、扶手箱灯带、中控侧边灯带全部联动【部分支持:当前只支持整车氛围灯统一颜色/亮度,不支持具体灯带分区联动。】
# 电感沙滩椅
## 座椅
1、水平电机:0mm
2、坐盆电机:0%
3、靠背电机:68°
4、腿托电机:43°
## 空调
1. 座舱锁定23℃恒温,双区分区独立控温,主副驾互不干扰;【部分支持:23℃ 恒温已落库;双区互不干扰策略需要更细的分区策略,当前未完整表达。】
2. 出风口自动上调水平上吹,关闭直吹头部 / 腿部风道,杜绝冷风刺激;【部分支持:已用 passenger face 近似表达上吹;水平上吹、关闭头部/腿部风道和防直吹策略暂不支持。】
## 天幕
1. 全车车窗自动完全升窗,隔绝路噪、风沙、强光;【暂不支持:当前没有车窗升降控制能力。】
2. 全景天幕遮阳帘 100% 完全关闭,后排侧隐私帘同步全关,无光静谧环境;【部分支持:全景天幕遮阳帘完全关闭已落库;后排侧隐私帘同步全关暂不支持。】
## 音响
全车音响自动播放低音量海浪 / 森林白噪音,ENC 主动降噪同步拉满,抵消路噪发动机噪音;导航、来电播报、多媒体铃声全部静音勿扰;【暂不支持:当前没有音响白噪音、ENC/主动降噪、导航/来电/多媒体勿扰控制能力。】
# 女王副驾(行驶)
## 座椅
1、水平电机:60mm
2、坐盆电机:37.5%
3、靠背电机:30°
4、腿托电机:22°
## 空调
1. 双区独立恒温锁定**23.5℃**,主驾温度不受干扰;
2. 副驾出风口自动上调水平上吹,关闭直吹头部、腿部风道,杜绝冷风刺激;【部分支持:已用 passenger face 近似表达上吹;水平上吹、关闭头部/腿部风道和防直吹策略暂不支持。】
3. 风量强制最低静音 1 档,自动切换内循环,开启 PM2.5 高压净化;【部分支持:风量 1 档和内循环已落库;PM2.5 高压净化暂不支持。】
# 小腿解压悬浮模式
## 座椅
1、水平电机:105
2、坐盆电机:75%
3、靠背电机:0
4、腿托电机:72°