backend
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
面向车载执行器的认知编排 Agent 单仓工程。系统把自然语言请求转换为可校验的语义配置,再由确定性的领域插件编译为 CAN 命令计划,最后经安全策略和执行器下发。
|
||||
|
||||
启动前端:PYTHONPATH=src:domains/seat/src .omx/python311-venv/bin/python -m cognitive_agent.server
|
||||
|
||||
## 建设阶段
|
||||
|
||||
- 阶段一:每个领域独立完成能力定义、功能配置生成、配置校验、CAN 编译和单执行器测试。
|
||||
|
||||
+151
-6
@@ -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", () => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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%。
|
||||
@@ -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 显示夹角换算。
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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°
|
||||
Reference in New Issue
Block a user