优化前端,优化前端指令,优化自定义控制
This commit is contained in:
+175
-36
@@ -175,6 +175,108 @@ operations:
|
||||
finally:
|
||||
self.service.mode_profiles = original_registry
|
||||
|
||||
def test_mode_card_editor_endpoint_persists_generated_can_flow(self) -> None:
|
||||
original_registry = self.service.mode_profiles
|
||||
original_artifact_root = self.service.artifact_root
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
preset_root = base / "presets"
|
||||
user_root = base / "user-modes"
|
||||
preset = preset_root / "vehicles" / "denza_n7" / "office.yaml"
|
||||
preset.parent.mkdir(parents=True)
|
||||
preset.write_text(
|
||||
"""
|
||||
id: office
|
||||
name: 办公模式
|
||||
scope:
|
||||
vehicle_ids: [denza_n7]
|
||||
operations:
|
||||
- domain: climate
|
||||
capability: climate.set_temperature
|
||||
target_scope: passenger
|
||||
parameters:
|
||||
temperature_c: 24
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
request_id = "req-mode-editor-api-001"
|
||||
instruction = "副驾空调温度设置为24度"
|
||||
artifact_root = base / "runs"
|
||||
request_dir = artifact_root / request_id
|
||||
request_dir.mkdir(parents=True)
|
||||
result = {
|
||||
"request_id": request_id,
|
||||
"utterance": instruction,
|
||||
"execution_mode": "dry_run",
|
||||
"control_transport": "wireless_mqtt",
|
||||
"function_configs": {
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"domain": "climate",
|
||||
"operations": [
|
||||
{
|
||||
"id": "passenger_temperature",
|
||||
"capability": "climate.set_temperature",
|
||||
"target": {"zone": "passenger"},
|
||||
"parameters": {"temperature_c": 24},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"command_plans": {
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"message": "Native_0x1DE",
|
||||
"signals": {
|
||||
"frame_id": "0x1DE",
|
||||
"data_hex": "00 00 00 00 00 18 00 00",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"execution_report": {"spec": {"results": []}},
|
||||
}
|
||||
(request_dir / "result.json").write_text(
|
||||
json.dumps(result, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
self.service.mode_profiles = ModeProfileRegistry(
|
||||
preset_root, user_root=user_root
|
||||
)
|
||||
self.service.artifact_root = artifact_root
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url}/api/mode-profiles/office",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(
|
||||
{
|
||||
"name": "专注办公模式",
|
||||
"instruction": instruction,
|
||||
"source_request_id": request_id,
|
||||
"vehicle_id": "denza_n7",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
)
|
||||
with self.opener.open(request, timeout=3) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(payload["storage"], "persistent_yaml")
|
||||
self.assertEqual(payload["mode_profile"]["origin"], "customized")
|
||||
self.assertEqual(payload["mode_profile"]["instruction"], instruction)
|
||||
self.assertTrue(
|
||||
(user_root / "vehicles" / "denza_n7" / "user" / "office.yaml").is_file()
|
||||
)
|
||||
finally:
|
||||
self.service.mode_profiles = original_registry
|
||||
self.service.artifact_root = original_artifact_root
|
||||
|
||||
def test_head_unit_display_sync_tracks_tasks_and_usb_catalog(self) -> None:
|
||||
task_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests",
|
||||
@@ -230,18 +332,18 @@ operations:
|
||||
def test_static_console_and_runtime_endpoint(self) -> None:
|
||||
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
||||
html = response.read().decode("utf-8")
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-90", timeout=3) as response:
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-101", timeout=3) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/gesture-control.js?v=android-pad-8", timeout=3
|
||||
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
|
||||
) as response:
|
||||
gesture_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/console-v2.js?v=sync-layout-27", timeout=3
|
||||
f"{self.base_url}/console-v2.js?v=sync-layout-41", timeout=3
|
||||
) as response:
|
||||
console_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/console-v2.css?v=sync-layout-27", timeout=3
|
||||
f"{self.base_url}/console-v2.css?v=sync-layout-40", timeout=3
|
||||
) as response:
|
||||
console_styles = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -263,7 +365,7 @@ operations:
|
||||
with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response:
|
||||
runtime = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
self.assertIn("Cognitive Agent", html)
|
||||
self.assertIn("511工程-智能座舱", html)
|
||||
self.assertIn('id="seatTwinCanvas"', html)
|
||||
self.assertIn('data-vehicle-model="byd-tang-2024-reference"', html)
|
||||
self.assertIn("整车孪生界面", html)
|
||||
@@ -276,12 +378,15 @@ operations:
|
||||
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
|
||||
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
|
||||
self.assertNotIn("透明车体 · 实体设备", html)
|
||||
self.assertIn('src="/app.js?v=dual-seat-90"', html)
|
||||
self.assertIn('href="/styles.css?v=dual-seat-10"', html)
|
||||
self.assertIn('src="/gesture-control.js?v=android-pad-8"', html)
|
||||
self.assertIn('src="/app.js?v=dual-seat-101"', html)
|
||||
self.assertIn('href="/styles.css?v=dual-seat-16"', html)
|
||||
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
|
||||
self.assertNotIn('class="vehicle-system-hud"', html)
|
||||
self.assertIn('src="/console-v2.js?v=sync-layout-27"', html)
|
||||
self.assertIn('href="/console-v2.css?v=sync-layout-27"', html)
|
||||
self.assertIn('src="/console-v2.js?v=sync-layout-41"', html)
|
||||
self.assertIn('href="/console-v2.css?v=sync-layout-40"', html)
|
||||
self.assertIn('<body class="console-v2">', html)
|
||||
self.assertIn('id="v2BootScreen"', html)
|
||||
self.assertIn('正在加载车机控制与实时状态', html)
|
||||
self.assertIn('src="/self-extension.js?v=sync-layout-26"', html)
|
||||
self.assertIn('href="/self-extension.css?v=sync-layout-26"', html)
|
||||
self.assertNotIn("self-calibration", html)
|
||||
@@ -291,7 +396,7 @@ operations:
|
||||
self.assertIn('aria-label="执行车舱控制指令"', html)
|
||||
self.assertNotIn('"admin"', console_script)
|
||||
self.assertNotIn("v2RoleSelect", console_script)
|
||||
self.assertIn('id="v2SidebarToggle"', console_script)
|
||||
self.assertIn('sidebarToggle.id = "v2SidebarToggle"', console_script)
|
||||
self.assertIn('id = "v2ModuleSwitcher"', console_script)
|
||||
self.assertIn(
|
||||
'panelHeading.classList.add("v2-operation-heading", "v2-module-heading")',
|
||||
@@ -316,28 +421,33 @@ operations:
|
||||
)
|
||||
self.assertIn('511工程-智能座舱', console_script)
|
||||
self.assertIn('网页控制链路', console_script)
|
||||
self.assertIn('<span>Hi,</span><strong>user</strong>', console_script)
|
||||
self.assertIn('["self-orchestration", "self-extension"]', console_script)
|
||||
self.assertNotIn('label: "自诊断"', console_script)
|
||||
self.assertIn('<small>手势模型</small>', console_script)
|
||||
self.assertIn('id="v2GestureModelValue">MediaPipe GR</strong>', console_script)
|
||||
self.assertIn('id="v2GestureModelNote">Tasks Vision · 0.10.14</small>', console_script)
|
||||
self.assertIn('<span class="v2-field-caption">配置信息</span>', console_script)
|
||||
self.assertIn('state.id = "v2VehicleState"', console_script)
|
||||
self.assertIn('fetch("/api/extension/health"', console_script)
|
||||
self.assertNotIn('runtimeControls.id = "v2RuntimeControls"', console_script)
|
||||
self.assertIn('function prepareRuntimeControls(originalForm)', console_script)
|
||||
self.assertIn('executionField.hidden = false', console_script)
|
||||
self.assertIn('insertVisibleControl(executionField, "v2-sync-execution-field")', console_script)
|
||||
self.assertIn('insertVisibleControl(simulationFaultField, "v2-sync-simulation-fault-field")', console_script)
|
||||
self.assertNotIn('class="v2-profile"', console_script)
|
||||
self.assertNotIn('class="v2-control-switch"', console_script)
|
||||
self.assertNotIn('class="v2-config-stack"', console_script)
|
||||
self.assertNotIn('id="v2GestureModelValue"', console_script)
|
||||
self.assertNotIn('id="v2VehicleState"', console_script)
|
||||
self.assertIn('actionRail.append(cameraToggle)', console_script)
|
||||
self.assertIn("function enhanceConfiguredDropdowns()", console_script)
|
||||
self.assertNotIn("function enhanceConfiguredDropdowns()", console_script)
|
||||
self.assertIn("function updateModeEdgeHints()", console_script)
|
||||
self.assertIn('classList.toggle("v2-twin-active", definition.index === 0)', console_script)
|
||||
self.assertIn('label: "执行结果与硬件回读"', console_script)
|
||||
self.assertIn("function requestUserSafeExecutionMode()", console_script)
|
||||
self.assertIn('const dryRunButton = $(\'[data-execution-mode="dry_run"]\')', console_script)
|
||||
self.assertIn('if (!dryRunButton.classList.contains("active")) dryRunButton.click()', console_script)
|
||||
self.assertIn('userTargetText.textContent = "执行目标:虚拟 CAN"', console_script)
|
||||
self.assertIn("user 固定使用虚拟 CAN 安全执行环境", console_script)
|
||||
self.assertNotIn("user 演示固定使用真实台架", console_script)
|
||||
self.assertIn("function keepRunButtonAvailable()", console_script)
|
||||
self.assertIn('window.setTimeout(() => $("#v2BootScreen")?.remove(), 220)', console_script)
|
||||
self.assertNotIn("function requestUserSafeExecutionMode()", console_script)
|
||||
self.assertNotIn('dryRunButton.click()', console_script)
|
||||
self.assertIn('const DEFAULT_EXECUTION_MODE = "hardware"', app_script)
|
||||
self.assertIn('preferredExecutionMode: DEFAULT_EXECUTION_MODE', app_script)
|
||||
self.assertIn('selectExecutionMode(modeAvailable ? preferredMode : "dry_run", { remember: false })', app_script)
|
||||
self.assertIn('class="mode-option active" id="hardwareMode"', html)
|
||||
self.assertIn('grid-template-columns: clamp(245px, 18.5vw, 310px)', console_styles)
|
||||
self.assertIn('.v2-control-switch {', console_styles)
|
||||
self.assertNotIn('.v2-control-switch {', console_styles)
|
||||
self.assertIn('.v2-module-switcher {', console_styles)
|
||||
self.assertIn('grid-template-columns: repeat(2, minmax(0, 1fr))', console_styles)
|
||||
self.assertIn(
|
||||
@@ -364,7 +474,7 @@ operations:
|
||||
self.assertIn('width: clamp(48px, 45%, 72px)', console_styles)
|
||||
self.assertIn('font-weight: 800', console_styles)
|
||||
self.assertIn('white-space: nowrap', console_styles)
|
||||
self.assertIn('.v2-gesture-model-field', console_styles)
|
||||
self.assertNotIn('.v2-gesture-model-field', console_styles)
|
||||
self.assertIn('.v2-inline-gesture .v2-camera-toggle', console_styles)
|
||||
self.assertIn('.v2-inline-gesture .v2-gesture-action-rail', console_styles)
|
||||
self.assertIn('grid-template-rows: 42px 10px 42px', console_styles)
|
||||
@@ -373,9 +483,8 @@ operations:
|
||||
self.assertIn('.v2-inline-gesture #gestureStopButton', console_styles)
|
||||
self.assertIn('.v2-inline-gesture .v2-gesture-result-stack', console_styles)
|
||||
self.assertIn('.v2-inline-gesture .v2-gesture-execution-slot', console_styles)
|
||||
self.assertIn('grid-template-columns: 1fr;', console_styles)
|
||||
self.assertIn('.v2-page-rail', console_styles)
|
||||
self.assertIn('.v2-dropdown-menu', console_styles)
|
||||
self.assertNotIn('.v2-dropdown-menu', console_styles)
|
||||
self.assertIn('.v2-edge-fade-left', console_styles)
|
||||
self.assertIn('.v2-edge-fade-right', console_styles)
|
||||
self.assertIn('.mode-group-divider', console_styles)
|
||||
@@ -385,8 +494,8 @@ operations:
|
||||
self.assertIn('.joint-seat-panel .thermal-states', console_styles)
|
||||
self.assertIn('.joint-twin-shell .domain-readouts', console_styles)
|
||||
self.assertIn('grid-template-columns: repeat(2, minmax(0, 1fr))', console_styles)
|
||||
self.assertIn('.v2-config-section > .v2-field-caption', console_styles)
|
||||
self.assertIn('.console-v2 .v2-config-card {', console_styles)
|
||||
self.assertNotIn('.v2-config-section', console_styles)
|
||||
self.assertNotIn('.console-v2 .v2-config-card {', console_styles)
|
||||
self.assertIn('.console-v2.v2-sidebar-collapsed .workspace', console_styles)
|
||||
self.assertNotIn('id="hardwareAccessField"', html)
|
||||
self.assertNotIn('id="hardwareClientLabel"', html)
|
||||
@@ -406,8 +515,14 @@ operations:
|
||||
self.assertIn('data-control-transport="wireless_mqtt"', html)
|
||||
self.assertIn('control_transport: state.controlTransport', app_script)
|
||||
self.assertIn('const transportField = $("#vehicleTransportField", originalForm)', console_script)
|
||||
self.assertIn('configStack.append(transportField)', console_script)
|
||||
self.assertNotIn('runtimeControls.append(transportField)', console_script)
|
||||
self.assertIn('insertVisibleControl(transportField, "v2-sync-transport-field")', console_script)
|
||||
self.assertIn('.v2-sync-execution-field .execution-mode-options', console_styles)
|
||||
self.assertIn('grid-template-columns: repeat(3, minmax(0, 1fr))', console_styles)
|
||||
self.assertNotIn('class="v2-sync-protocol"', console_script)
|
||||
self.assertIn('.console-v2 .v2-transport-field .vehicle-transport-options', console_styles)
|
||||
self.assertIn('.v2-sync-transport-field .mode-option', console_styles)
|
||||
self.assertIn('overflow-wrap: anywhere', console_styles)
|
||||
self.assertIn('button.disabled = false', app_script)
|
||||
self.assertIn('button.setAttribute("aria-pressed", String(selected))', app_script)
|
||||
self.assertNotIn(
|
||||
@@ -497,7 +612,7 @@ operations:
|
||||
self.assertIn('client_type: "web_console"', app_script)
|
||||
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
||||
self.assertIn('"cognitive-agent:module-change"', app_script)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v179"', service_worker)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v202"', service_worker)
|
||||
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
|
||||
self.assertIn(".then(() => self.skipWaiting())", service_worker)
|
||||
self.assertNotIn("const cached = await caches.match(request)", service_worker)
|
||||
@@ -544,6 +659,13 @@ operations:
|
||||
self.assertIn('id="modeGallery"', html)
|
||||
self.assertIn('id="modeGalleryGroups"', html)
|
||||
self.assertIn("全部功能模式", html)
|
||||
self.assertIn('id="modeEditorBackdrop"', html)
|
||||
self.assertIn('id="modeEditorName"', html)
|
||||
self.assertIn('id="modeEditorInstruction"', html)
|
||||
self.assertIn('id="modeEditorExecute"', html)
|
||||
self.assertIn('id="modeEditorComplete"', html)
|
||||
self.assertIn('id="modeEditorFlowPreview"', html)
|
||||
self.assertIn('class="mode-editor-flow-preview empty"', html)
|
||||
self.assertIn('id="saveModeSource"', html)
|
||||
self.assertNotIn("主驾 24℃", html)
|
||||
populate_modes = app_script[
|
||||
@@ -568,6 +690,17 @@ operations:
|
||||
self.assertIn('deleteUserModeProfile(deleteButton.dataset.modeDelete)', app_script)
|
||||
self.assertIn('method: "DELETE"', app_script)
|
||||
self.assertIn('deleteButton.dataset.modeDelete = mode.id', app_script)
|
||||
self.assertIn('editButton.dataset.modeEdit = mode.id', app_script)
|
||||
self.assertIn("function openModeEditor", app_script)
|
||||
self.assertIn("function executeModeEditorInstruction", app_script)
|
||||
self.assertIn("function completeModeEditor", app_script)
|
||||
self.assertIn("function modeEditorReadableCommands", app_script)
|
||||
self.assertIn("function modeEditorMessagesForOperation", app_script)
|
||||
self.assertIn("function modeEditorAppendCommandCard", app_script)
|
||||
self.assertIn('title.textContent = flow?.control_transport === "wireless_mqtt" ? "MQTT 消息序列"', app_script)
|
||||
self.assertIn("message.payload", app_script)
|
||||
self.assertIn("source_request_id: editor.sourceRequestId", app_script)
|
||||
self.assertIn('method: "POST"', app_script)
|
||||
self.assertIn('data-domain-panel="climate"', html)
|
||||
self.assertIn('data-domain-panel="roof"', html)
|
||||
self.assertIn('data-domain-panel="ambient_light"', html)
|
||||
@@ -624,7 +757,10 @@ operations:
|
||||
self.assertNotIn('id="transmittanceValue"', html)
|
||||
self.assertNotIn('id="roofMotorState"', html)
|
||||
self.assertIn('id="ambientBrightness"', html)
|
||||
self.assertIn("坐垫脚托夹角133度", html)
|
||||
self.assertNotIn('class="example-group"', html)
|
||||
self.assertNotIn('id="safetyStrip"', html)
|
||||
self.assertNotIn('class="pipeline"', html)
|
||||
self.assertNotIn('class="topbar"', html)
|
||||
self.assertNotIn('data-axis="height"', html)
|
||||
self.assertEqual(runtime["domain"], "cabin")
|
||||
self.assertEqual(
|
||||
@@ -632,6 +768,7 @@ operations:
|
||||
(
|
||||
runtime["hardware"]["configured"]
|
||||
or runtime["vehicle_can"]["configured"]
|
||||
or runtime["vehicle_mqtt"]["configured"]
|
||||
),
|
||||
)
|
||||
self.assertEqual(runtime["hardware"]["supported_targets"], ["front_right"])
|
||||
@@ -686,11 +823,13 @@ operations:
|
||||
for item in runtime["capabilities"]
|
||||
if item["name"].startswith("seat.")
|
||||
and not item["name"].startswith("seat.set_driver_")
|
||||
and item["name"] != "seat.recall_driver_memory_native"
|
||||
]
|
||||
driver_seat_capabilities = [
|
||||
item
|
||||
for item in runtime["capabilities"]
|
||||
if item["name"].startswith("seat.set_driver_")
|
||||
or item["name"] == "seat.recall_driver_memory_native"
|
||||
]
|
||||
self.assertTrue(passenger_seat_capabilities)
|
||||
self.assertTrue(
|
||||
@@ -713,7 +852,7 @@ operations:
|
||||
) as response:
|
||||
extension_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/app.js?v=dual-seat-90", timeout=3
|
||||
f"{self.base_url}/app.js?v=dual-seat-101", timeout=3
|
||||
) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
|
||||
@@ -1152,7 +1291,7 @@ operations:
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(
|
||||
{
|
||||
"utterance": "打开副驾座椅通风",
|
||||
"utterance": "打开副驾座椅通风,同时播放音乐",
|
||||
"provider": "openai",
|
||||
"conversation_id": "conv-e2e-gpt-fallback",
|
||||
"execution_mode": "dry_run",
|
||||
|
||||
+2
-6
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"extends": "ch1_vehicle_frontend_hil_suite.json",
|
||||
"suite": "ch1-vehicle-door-rejection-contract",
|
||||
"description": "Offline-only fail-closed contract for retired CH1 door-lock commands. It inherits the complete non-door catalog, safety policy, rejection matrix, and nine-step cleanup baseline from the full front-end suite.",
|
||||
"description": "Offline-only fail-closed contract for unsupported whole-vehicle CH1 door-lock commands. Rear-door 0x32B candidates remain contract-only in the inherited catalog and are not live cases in this suite.",
|
||||
"rejection_only": true,
|
||||
"smoke_scope": {
|
||||
"retired_candidate_command_frame_id": "0x496",
|
||||
"retirement_reason": "Physical lock verification failed; cleanup succeeded, so no door-lock command remains eligible for live HIL.",
|
||||
"retirement_reason": "Whole-vehicle 0x496 verification failed; the distinct rear-only 0x32B command remains an offline contract candidate until current-vehicle HIL evidence is recorded.",
|
||||
"live_execution_permitted_by_fixture_alone": false
|
||||
},
|
||||
"modules": {
|
||||
@@ -21,8 +21,6 @@
|
||||
"REJECT-RAW-CAN",
|
||||
"REJECT-VEHICLE-LOCK",
|
||||
"REJECT-VEHICLE-UNLOCK",
|
||||
"REJECT-REAR-LOCK",
|
||||
"REJECT-REAR-UNLOCK",
|
||||
"REJECT-WALL-LIGHT",
|
||||
"REJECT-READING-ZONE",
|
||||
"REJECT-AMBIENT-SCENE",
|
||||
@@ -41,8 +39,6 @@
|
||||
"REJECT-RAW-CAN",
|
||||
"REJECT-VEHICLE-LOCK",
|
||||
"REJECT-VEHICLE-UNLOCK",
|
||||
"REJECT-REAR-LOCK",
|
||||
"REJECT-REAR-UNLOCK",
|
||||
"REJECT-WALL-LIGHT",
|
||||
"REJECT-READING-ZONE",
|
||||
"REJECT-AMBIENT-SCENE",
|
||||
|
||||
+10
-4
@@ -58,6 +58,7 @@
|
||||
"case_ids": [
|
||||
"CLIMATE-POWER-ON", "CLIMATE-POWER-OFF", "CLIMATE-FAN-3",
|
||||
"CLIMATE-MODE-DEFROST", "CLIMATE-TEMP-DRIVER-24", "CLIMATE-CONTROL-AUTO",
|
||||
"DOOR-REAR-LOCK", "DOOR-REAR-UNLOCK",
|
||||
"WINDOW-FRONT-LEFT-10", "WINDOW-FRONT-LEFT-50", "WINDOW-FRONT-LEFT-0",
|
||||
"OUTLET-DRIVER-LEFT-CENTER",
|
||||
"SEAT-DRIVER-VENT-LOW", "SEAT-DRIVER-VENT-HIGH", "SEAT-DRIVER-VENT-OFF",
|
||||
@@ -75,7 +76,6 @@
|
||||
"REJECT-CH0", "REJECT-PASSENGER", "REJECT-FRONT-RIGHT",
|
||||
"REJECT-T2G", "REJECT-IAR", "REJECT-RAW-CAN",
|
||||
"REJECT-VEHICLE-LOCK", "REJECT-VEHICLE-UNLOCK",
|
||||
"REJECT-REAR-LOCK", "REJECT-REAR-UNLOCK",
|
||||
"REJECT-WALL-LIGHT", "REJECT-READING-ZONE", "REJECT-AMBIENT-SCENE", "REJECT-AMBIENT-RGB"
|
||||
]
|
||||
},
|
||||
@@ -84,6 +84,7 @@
|
||||
"case_ids": [
|
||||
"CLIMATE-POWER-ON", "CLIMATE-POWER-OFF", "CLIMATE-FAN-3",
|
||||
"CLIMATE-MODE-DEFROST", "CLIMATE-TEMP-DRIVER-24", "CLIMATE-CONTROL-AUTO",
|
||||
"DOOR-REAR-LOCK", "DOOR-REAR-UNLOCK",
|
||||
"WINDOW-FRONT-LEFT-10", "WINDOW-FRONT-LEFT-50", "WINDOW-FRONT-LEFT-0",
|
||||
"OUTLET-DRIVER-LEFT-CENTER",
|
||||
"SEAT-DRIVER-VENT-LOW", "SEAT-DRIVER-VENT-HIGH", "SEAT-DRIVER-VENT-OFF",
|
||||
@@ -101,7 +102,6 @@
|
||||
"REJECT-CH0", "REJECT-PASSENGER", "REJECT-FRONT-RIGHT",
|
||||
"REJECT-T2G", "REJECT-IAR", "REJECT-RAW-CAN",
|
||||
"REJECT-VEHICLE-LOCK", "REJECT-VEHICLE-UNLOCK",
|
||||
"REJECT-REAR-LOCK", "REJECT-REAR-UNLOCK",
|
||||
"REJECT-WALL-LIGHT", "REJECT-READING-ZONE", "REJECT-AMBIENT-SCENE", "REJECT-AMBIENT-RGB"
|
||||
]
|
||||
}
|
||||
@@ -131,6 +131,14 @@
|
||||
"id": "CLIMATE-CONTROL-AUTO", "command": "ac control auto",
|
||||
"expected": {"config_key": "native_climate", "domain": "climate", "capability": "climate.set_control_mode", "target": "vehicle", "frame_ids": ["0x1DE"], "feedback_ids": ["0x404"], "state_assertions": [{"path": "domain_state.climate.control_mode", "equals": "auto"}]}
|
||||
},
|
||||
{
|
||||
"id": "DOOR-REAR-LOCK", "command": "door rear lock", "hil": false,
|
||||
"expected": {"config_key": "native_door", "domain": "door", "capability": "door.set_rear_lock", "target": "rear", "frame_ids": ["0x32B"], "feedback_ids": ["0x407"], "state_assertions": [{"path": "domain_state.door.locks.rear_left.state", "equals": "lock"}, {"path": "domain_state.door.locks.rear_right.state", "equals": "lock"}]}
|
||||
},
|
||||
{
|
||||
"id": "DOOR-REAR-UNLOCK", "command": "door rear unlock", "hil": false,
|
||||
"expected": {"config_key": "native_door", "domain": "door", "capability": "door.set_rear_lock", "target": "rear", "frame_ids": ["0x32B"], "feedback_ids": ["0x407"], "state_assertions": [{"path": "domain_state.door.locks.rear_left.state", "equals": "unlock"}, {"path": "domain_state.door.locks.rear_right.state", "equals": "unlock"}]}
|
||||
},
|
||||
{
|
||||
"id": "WINDOW-FRONT-LEFT-10", "command": "window front-left position 10", "action_timeout_seconds": 20,
|
||||
"expected": {"config_key": "native_window", "domain": "window", "capability": "window.set_position", "target": "front_left", "frame_ids": ["0x431"], "feedback_ids": ["0x388"], "state_assertions": [{"path": "domain_state.window.positions.front_left.position_pct", "equals": 10, "tolerance": 3}]}
|
||||
@@ -243,8 +251,6 @@
|
||||
{"id": "REJECT-RAW-CAN", "command": "raw can id 0x431 payload 00 00 00 00 00 00 01 00", "expected_category": "safety_rejection"},
|
||||
{"id": "REJECT-VEHICLE-LOCK", "command": "door vehicle lock", "expected_category": "safety_rejection"},
|
||||
{"id": "REJECT-VEHICLE-UNLOCK", "command": "door vehicle unlock", "expected_category": "safety_rejection"},
|
||||
{"id": "REJECT-REAR-LOCK", "command": "door rear lock", "expected_category": "safety_rejection"},
|
||||
{"id": "REJECT-REAR-UNLOCK", "command": "door rear unlock", "expected_category": "safety_rejection"},
|
||||
{"id": "REJECT-WALL-LIGHT", "command": "light wall on", "expected_category": "safety_rejection"},
|
||||
{"id": "REJECT-READING-ZONE", "command": "light reading front-left on", "expected_category": "safety_rejection"},
|
||||
{"id": "REJECT-AMBIENT-SCENE", "command": "light ambient scene cinema", "expected_category": "safety_rejection"},
|
||||
|
||||
@@ -231,9 +231,9 @@ class Ch1VehicleHilContractTests(unittest.TestCase):
|
||||
orchestration = suite_cases(self.suite, "self_orchestration")
|
||||
extension = suite_cases(self.suite, "self_extension")
|
||||
self.assertEqual([case["id"] for case in orchestration], [case["id"] for case in extension])
|
||||
self.assertEqual(len(orchestration), 43)
|
||||
self.assertEqual(len(orchestration), 45)
|
||||
self.assertEqual(
|
||||
len(suite_rejections(self.suite, "self_orchestration")), 14
|
||||
len(suite_rejections(self.suite, "self_orchestration")), 12
|
||||
)
|
||||
self.assertEqual(sum(case.get("hil", True) for case in orchestration), 30)
|
||||
self.assertEqual(len(self.suite["cleanup"]), 9)
|
||||
@@ -271,8 +271,6 @@ class Ch1VehicleHilContractTests(unittest.TestCase):
|
||||
retired = {
|
||||
"REJECT-VEHICLE-LOCK": "door vehicle lock",
|
||||
"REJECT-VEHICLE-UNLOCK": "door vehicle unlock",
|
||||
"REJECT-REAR-LOCK": "door rear lock",
|
||||
"REJECT-REAR-UNLOCK": "door rear unlock",
|
||||
}
|
||||
positive_commands = {case["command"] for case in self.suite["case_catalog"]}
|
||||
rejection_by_id = {
|
||||
@@ -400,8 +398,6 @@ class Ch1VehicleHilContractTests(unittest.TestCase):
|
||||
"REJECT-RAW-CAN",
|
||||
"REJECT-VEHICLE-LOCK",
|
||||
"REJECT-VEHICLE-UNLOCK",
|
||||
"REJECT-REAR-LOCK",
|
||||
"REJECT-REAR-UNLOCK",
|
||||
"REJECT-WALL-LIGHT",
|
||||
"REJECT-READING-ZONE",
|
||||
"REJECT-AMBIENT-SCENE",
|
||||
@@ -657,9 +653,9 @@ class Ch1VehicleHilContractTests(unittest.TestCase):
|
||||
hardware_access_bypass=True,
|
||||
)
|
||||
|
||||
interpret.assert_called_once()
|
||||
interpret.assert_not_called()
|
||||
self.assertEqual(vehicle_service.process.call_args.args[1], "openai")
|
||||
self.assertEqual(response["provider_used"], "openai:gpt-5.6-sol")
|
||||
self.assertEqual(response["provider_used"], "local_rules")
|
||||
self.assertTrue(response["success"])
|
||||
self.assertEqual(len(response["operation_results"]), 1)
|
||||
self.assertEqual(len(captured), 1)
|
||||
@@ -742,7 +738,9 @@ class Ch1VehicleHilContractTests(unittest.TestCase):
|
||||
|
||||
interpret.assert_called_once()
|
||||
self.assertEqual(vehicle_service.process.call_args.args[1], "openai")
|
||||
self.assertEqual(response["provider_used"], "openai:gpt-5.6-sol")
|
||||
self.assertEqual(
|
||||
response["provider_used"], "local_rules+openai:gpt-5.6-sol"
|
||||
)
|
||||
self.assertTrue(response["success"])
|
||||
self.assertEqual(len(response["operation_results"]), 2)
|
||||
self.assertEqual(len(captured), 1)
|
||||
@@ -758,8 +756,6 @@ class Ch1VehicleHilContractTests(unittest.TestCase):
|
||||
retired_door_ids = {
|
||||
"REJECT-VEHICLE-LOCK",
|
||||
"REJECT-VEHICLE-UNLOCK",
|
||||
"REJECT-REAR-LOCK",
|
||||
"REJECT-REAR-UNLOCK",
|
||||
}
|
||||
for index, case in enumerate(
|
||||
suite_rejections(self.suite, "self_orchestration")
|
||||
@@ -1081,7 +1077,7 @@ class Ch1VehicleHilContractTests(unittest.TestCase):
|
||||
report["summary"]["offline_rejection_cases"],
|
||||
2 * len(fixture["rejection_catalog"]),
|
||||
)
|
||||
self.assertEqual(len(fixture["rejection_catalog"]), 14)
|
||||
self.assertEqual(len(fixture["rejection_catalog"]), 12)
|
||||
self.assertEqual(
|
||||
[(path, method) for path, method, _ in api.calls],
|
||||
[("/api/runtime", "GET")],
|
||||
|
||||
@@ -510,6 +510,88 @@ class MultiDomainServiceTests(unittest.TestCase):
|
||||
[],
|
||||
)
|
||||
|
||||
def test_mode_card_editor_persists_only_server_generated_command_flow(self) -> None:
|
||||
mode_root = Path(self.temporary_directory.name) / "mode-card-presets"
|
||||
user_root = Path(self.temporary_directory.name) / "mode-card-user"
|
||||
preset = mode_root / "vehicles" / "denza_n7" / "office.yaml"
|
||||
preset.parent.mkdir(parents=True)
|
||||
preset.write_text(
|
||||
"""
|
||||
id: office
|
||||
name: 办公模式
|
||||
icon: briefcase
|
||||
scope:
|
||||
vehicle_ids: [denza_n7]
|
||||
operations:
|
||||
- domain: climate
|
||||
capability: climate.set_temperature
|
||||
target_scope: passenger
|
||||
parameters:
|
||||
temperature_c: 24
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.service.mode_profiles = ModeProfileRegistry(
|
||||
mode_root, user_root=user_root
|
||||
)
|
||||
model_output = {
|
||||
"detected_domains": ["climate"],
|
||||
"requests": [
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "passenger",
|
||||
"operation": "set",
|
||||
"parameters": [{"name": "temperature_c", "value": 24}],
|
||||
"reason": "办公温度。",
|
||||
}
|
||||
],
|
||||
"ambiguities": [],
|
||||
"decision_summary": ["副驾温度设置为24度。"],
|
||||
"confidence": 0.97,
|
||||
}
|
||||
instruction = "副驾空调温度设置为24度"
|
||||
with patch.object(self.service.openai, "interpret", return_value=model_output):
|
||||
generated = self.service.process(
|
||||
instruction,
|
||||
"openai",
|
||||
"conv-mode-card-editor",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
|
||||
saved = self.service.save_mode_profile_card(
|
||||
"office",
|
||||
"专注办公模式",
|
||||
instruction,
|
||||
generated["request_id"],
|
||||
"denza_n7",
|
||||
)
|
||||
|
||||
self.assertEqual(saved["status"], "succeeded")
|
||||
self.assertEqual(saved["storage"], "persistent_yaml")
|
||||
self.assertEqual(saved["mode_profile"]["origin"], "customized")
|
||||
self.assertEqual(saved["mode_profile"]["instruction"], instruction)
|
||||
self.assertTrue(saved["mode_profile"]["command_flow"]["plans"])
|
||||
self.assertTrue(
|
||||
saved["mode_profile"]["command_flow"]["function_configs"]
|
||||
)
|
||||
reloaded = ModeProfileRegistry(mode_root, user_root=user_root)
|
||||
persisted = next(
|
||||
item
|
||||
for item in reloaded.public_modes("denza_n7")
|
||||
if item["id"] == "office"
|
||||
)
|
||||
self.assertEqual(persisted["name"], "专注办公模式")
|
||||
with self.assertRaisesRegex(ValueError, "重新点击执行"):
|
||||
self.service.save_mode_profile_card(
|
||||
"office",
|
||||
"专注办公模式",
|
||||
"副驾空调温度设置为25度",
|
||||
generated["request_id"],
|
||||
"denza_n7",
|
||||
)
|
||||
|
||||
def test_build_named_mode_executes_and_persists_in_one_turn(self) -> None:
|
||||
mode_root = Path(self.temporary_directory.name) / "modes"
|
||||
self.service.mode_profiles = ModeProfileRegistry(mode_root)
|
||||
|
||||
@@ -46,7 +46,12 @@ class SeatFrontendInputCoverageTests(unittest.TestCase):
|
||||
cls.temporary_directory = tempfile.TemporaryDirectory()
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false"},
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_MODE_PROFILE_ROOT": str(
|
||||
Path(cls.temporary_directory.name) / "mode-profiles"
|
||||
),
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
cls.service = SeatDemoService(REPOSITORY_ROOT)
|
||||
|
||||
@@ -266,7 +266,7 @@ class Ch1DefaultTargetTests(unittest.TestCase):
|
||||
self.assertEqual(seat["target_scope"], "driver")
|
||||
self.assertEqual(explicit_passenger["target_scope"], "passenger")
|
||||
|
||||
def test_gpt_provider_is_preserved_while_wrong_model_targets_are_replaced(self) -> None:
|
||||
def test_local_rules_skip_gpt_and_apply_safe_ch1_defaults(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
"车窗开启一半",
|
||||
@@ -336,8 +336,8 @@ class Ch1DefaultTargetTests(unittest.TestCase):
|
||||
conversation_id=f"conv-ch1-default-openai-{index}",
|
||||
)
|
||||
|
||||
interpret.assert_called_once()
|
||||
self.assertEqual(result["provider_used"], "openai:gpt-5.6-sol")
|
||||
interpret.assert_not_called()
|
||||
self.assertEqual(result["provider_used"], "local_rules")
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(set(result["function_configs"]), {config_key})
|
||||
self.assertNotIn("seat", result["function_configs"])
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -109,6 +111,8 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
"seat.set_driver_heating_native",
|
||||
"seat.set_driver_massage_native",
|
||||
"seat.set_driver_axis_native",
|
||||
"seat.set_driver_position_native",
|
||||
"seat.recall_driver_memory_native",
|
||||
},
|
||||
)
|
||||
for capability in capabilities.values():
|
||||
@@ -151,6 +155,16 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
{"axis": "height", "direction": "stop", "duration_ms": 500},
|
||||
"driver",
|
||||
),
|
||||
"seat driver position 3": (
|
||||
"seat.set_driver_position_native",
|
||||
{"slot": 3},
|
||||
"driver",
|
||||
),
|
||||
"seat driver memory recall 2": (
|
||||
"seat.recall_driver_memory_native",
|
||||
{"slot": 2},
|
||||
"driver",
|
||||
),
|
||||
}
|
||||
for utterance, expected in cases.items():
|
||||
with self.subTest(utterance=utterance):
|
||||
@@ -196,6 +210,10 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "height", "direction": "up", "duration_ms": 500},
|
||||
),
|
||||
"主驾座椅调整到记忆位置3": (
|
||||
"seat.recall_driver_memory_native",
|
||||
{"slot": 3},
|
||||
),
|
||||
}
|
||||
for utterance, expected in cases.items():
|
||||
with self.subTest(utterance=utterance):
|
||||
@@ -356,6 +374,142 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
self.assertEqual(command["repeat"], {"count": 3, "period_ms": 100})
|
||||
self.assertNotIn("special_handler", command["signals"])
|
||||
|
||||
def test_memory_aliases_compile_the_same_learned_twenty_frame_sequence(self) -> None:
|
||||
commands = []
|
||||
for capability in (
|
||||
"seat.set_driver_position_native",
|
||||
"seat.recall_driver_memory_native",
|
||||
):
|
||||
with self.subTest(capability=capability):
|
||||
_, plan = self._compile(capability, {"slot": 3})
|
||||
command = plan["spec"]["commands"][0]
|
||||
commands.append(command)
|
||||
self.assertEqual(command["signals"]["frame_id"], "0x4C1")
|
||||
self.assertEqual(
|
||||
command["signals"]["special_handler"],
|
||||
"driver_seat_memory_recall",
|
||||
)
|
||||
special = json.loads(command["signals"]["special_parameters"])
|
||||
self.assertEqual(len(special["frame_sequence"]), 20)
|
||||
self.assertEqual(
|
||||
special["frame_sequence"][0::2],
|
||||
["16 9A 66 3C 76 04 06 00"] * 10,
|
||||
)
|
||||
self.assertEqual(
|
||||
special["frame_sequence"][1::2],
|
||||
["15 02 C6 3E 2A C8 FF FF"] * 10,
|
||||
)
|
||||
self.assertEqual(special["period_ms"], 50)
|
||||
public = self.runtime.command_frame_public(command)
|
||||
self.assertEqual(public["sequence"]["frame_count"], 20)
|
||||
self.assertTrue(
|
||||
public["sequence"]["requires_fresh_initialized_0x494"]
|
||||
)
|
||||
self.assertEqual(
|
||||
commands[0]["signals"]["special_parameters"],
|
||||
commands[1]["signals"]["special_parameters"],
|
||||
)
|
||||
|
||||
def test_memory_executor_requires_initialized_status_and_alternates_frames(self) -> None:
|
||||
config, plan = self._compile(
|
||||
"seat.recall_driver_memory_native", {"slot": 2}
|
||||
)
|
||||
initialized = CanFrame(
|
||||
0x494,
|
||||
bytes((0, 0, 0, 0, 0, 0, 0, 0x3C)),
|
||||
channel=1,
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
)
|
||||
bus = _FakeBus([initialized, initialized])
|
||||
with patch("cognitive_agent.application.native_can_runtime.time.sleep"):
|
||||
report = NativeCanExecutor(
|
||||
lambda: bus, feedback_timeout_ms=1, feedback_required=False
|
||||
).execute(self.runtime, plan, config)
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(len(bus.sent), 20)
|
||||
self.assertTrue(all(frame.is_fd and frame.bitrate_switch for frame in bus.sent))
|
||||
self.assertEqual(
|
||||
[frame.data[0] for frame in bus.sent], [0x16, 0x15] * 10
|
||||
)
|
||||
self.assertEqual(
|
||||
[frame.data for frame in bus.sent[0::2]],
|
||||
[bytes.fromhex("16 9A 66 3C 76 04 06 00")] * 10,
|
||||
)
|
||||
self.assertEqual(
|
||||
[frame.data for frame in bus.sent[1::2]],
|
||||
[bytes.fromhex("15 02 A6 AE 00 C8 FF FF")] * 10,
|
||||
)
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertFalse(telemetry["physical_effect_confirmed"])
|
||||
self.assertEqual(len(telemetry["sent_frames"]), 20)
|
||||
|
||||
uninitialized_bus = _FakeBus(
|
||||
[CanFrame(0x494, bytes(8), channel=1, is_fd=True)]
|
||||
)
|
||||
rejected = NativeCanExecutor(
|
||||
lambda: uninitialized_bus,
|
||||
feedback_timeout_ms=1,
|
||||
feedback_required=False,
|
||||
).execute(self.runtime, plan, config)
|
||||
self.assertEqual(rejected["spec"]["status"], "failed")
|
||||
self.assertFalse(uninitialized_bus.sent)
|
||||
|
||||
def test_memory_sequence_stops_immediately_on_send_failure(self) -> None:
|
||||
config, plan = self._compile(
|
||||
"seat.set_driver_position_native", {"slot": 1}
|
||||
)
|
||||
initialized = CanFrame(
|
||||
0x494, bytes((0, 0, 0, 0, 0, 0, 0, 0x3C)), channel=1
|
||||
)
|
||||
bus = _FakeBus([initialized], fail_on_calls={4})
|
||||
with patch("cognitive_agent.application.native_can_runtime.time.sleep"):
|
||||
report = NativeCanExecutor(
|
||||
lambda: bus, feedback_timeout_ms=1, feedback_required=False
|
||||
).execute(self.runtime, plan, config)
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
self.assertEqual(bus.send_calls, 4)
|
||||
self.assertEqual(len(bus.sent), 3)
|
||||
|
||||
def test_memory_compile_rejects_missing_or_malformed_learned_slots(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "driver_memory.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"positions": {
|
||||
"1": {
|
||||
"payload_15": "15 00",
|
||||
"payload_16": "16 00 00 00 00 00 00 00",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH": str(path)},
|
||||
):
|
||||
config = self.runtime.build_function_config(
|
||||
"seat",
|
||||
[_native_action("seat.recall_driver_memory_native", {"slot": 1})],
|
||||
"req-bad-memory",
|
||||
"local_rules",
|
||||
)
|
||||
with self.assertRaisesRegex(NativeCanCompileError, "8-byte"):
|
||||
self.runtime.compile(config)
|
||||
|
||||
missing = self.runtime.build_function_config(
|
||||
"seat",
|
||||
[_native_action("seat.recall_driver_memory_native", {"slot": 2})],
|
||||
"req-missing-memory",
|
||||
"local_rules",
|
||||
)
|
||||
with self.assertRaisesRegex(NativeCanCompileError, "has not been learned"):
|
||||
self.runtime.compile(missing)
|
||||
|
||||
def test_compiler_fails_closed_for_wrong_seat_or_invalid_parameters(self) -> None:
|
||||
for target in ("passenger", "front_right"):
|
||||
action = _native_action(
|
||||
|
||||
@@ -128,18 +128,18 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
||||
):
|
||||
self.assertIn(f'{state_path} === "physical_can_ch1"', summary_source)
|
||||
|
||||
def test_orchestration_renders_only_the_physical_ch1_patch(self) -> None:
|
||||
def test_orchestration_renders_only_the_authoritative_ch1_patch(self) -> None:
|
||||
apply_source = function_source(
|
||||
self.app_source,
|
||||
"function applyAuthoritativeVehicleState(payload) {",
|
||||
'\nwindow.addEventListener("cognitive-agent:extension-vehicle-result"',
|
||||
)
|
||||
self.assertIn(
|
||||
"const actuatorPatch = physicalCh1StatePatch(payload.actuator_state)",
|
||||
"const actuatorPatch = authoritativeCh1StatePatch(payload.actuator_state)",
|
||||
apply_source,
|
||||
)
|
||||
self.assertIn(
|
||||
"const domainPatch = physicalCh1StatePatch(payload.domain_state)",
|
||||
"const domainPatch = authoritativeCh1StatePatch(payload.domain_state)",
|
||||
apply_source,
|
||||
)
|
||||
self.assertIn("renderSeatState(actuatorPatch)", apply_source)
|
||||
@@ -163,13 +163,18 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
||||
self.assertIn("renderSeatState(result.actuator_state)", ch1_branch)
|
||||
self.assertIn("renderDomainState(result.domain_state)", ch1_branch)
|
||||
|
||||
def test_physical_patch_does_not_promote_unmarked_sibling_domains(self) -> None:
|
||||
def test_authoritative_patch_accepts_wired_and_wireless_without_promoting_siblings(self) -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
self.skipTest("Node.js is required to execute the frontend patch helper")
|
||||
source_helper = function_source(
|
||||
self.app_source,
|
||||
"function isAuthoritativeCh1Source(value) {",
|
||||
"\nfunction renderCabinLiveStatus",
|
||||
)
|
||||
patch_source = function_source(
|
||||
self.app_source,
|
||||
"function physicalCh1StatePatch(value) {",
|
||||
"function authoritativeCh1StatePatch(value) {",
|
||||
"\nfunction isAuthoritativeVehicleStatePayload",
|
||||
)
|
||||
state = {
|
||||
@@ -178,14 +183,20 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
||||
"power": "on",
|
||||
},
|
||||
"roof": {"shade_position_pct": 100},
|
||||
"ambient_light": {
|
||||
"feedback_source": "wireless_mqtt_ch1",
|
||||
"power": "on",
|
||||
},
|
||||
"window": {
|
||||
"positions": {"front_left": {"position_pct": 50}}
|
||||
},
|
||||
}
|
||||
script = (
|
||||
'const AUTHORITATIVE_CH1_SOURCES = new Set(["physical_can_ch1", "wireless_mqtt_ch1"]);\n'
|
||||
f"{source_helper}\n"
|
||||
f"{patch_source}\n"
|
||||
f"const input = {json.dumps(state)};\n"
|
||||
"process.stdout.write(JSON.stringify(physicalCh1StatePatch(input)));"
|
||||
"process.stdout.write(JSON.stringify(authoritativeCh1StatePatch(input)));"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[node, "-e", script],
|
||||
@@ -195,7 +206,10 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
json.loads(completed.stdout),
|
||||
{"climate": state["climate"]},
|
||||
{
|
||||
"climate": state["climate"],
|
||||
"ambient_light": state["ambient_light"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_dual_channel_runtime_polls_each_seat_without_aliasing(self) -> None:
|
||||
@@ -249,6 +263,24 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
||||
self.assertIn("}, physicalStatePollIntervalMs());", self.app_source)
|
||||
self.assertIn("restartPhysicalStatePolling();", self.app_source)
|
||||
|
||||
def test_transport_switch_clears_stale_vehicle_values_before_new_feedback(self) -> None:
|
||||
select_source = function_source(
|
||||
self.app_source,
|
||||
"function selectControlTransport(transport) {",
|
||||
"\nfunction renderHardwareAccess",
|
||||
)
|
||||
reset_source = function_source(
|
||||
self.app_source,
|
||||
"function resetVehicleStatusDisplay() {",
|
||||
"\nfunction mergeSeatActuatorState",
|
||||
)
|
||||
|
||||
self.assertIn("resetVehicleStatusDisplay();", select_source)
|
||||
self.assertIn('setText("#windowState", "UNKNOWN")', reset_source)
|
||||
self.assertIn('setText("#frontLightState", "UNKNOWN")', reset_source)
|
||||
self.assertIn('setText("#cabinSummaryDoors", "状态未知")', reset_source)
|
||||
self.assertIn('meter.dataset.state = "unknown"', reset_source)
|
||||
|
||||
def test_vehicle_selection_cannot_replay_unfiltered_state_in_ch1_mode(self) -> None:
|
||||
select_source = function_source(
|
||||
self.app_source,
|
||||
|
||||
@@ -356,6 +356,85 @@ operations:
|
||||
[{"name": "transmittance_pct", "value": 20}],
|
||||
)
|
||||
|
||||
def test_mode_card_override_persists_instruction_and_can_command_flow(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "presets"
|
||||
user_root = Path(directory) / "persistent-user-modes"
|
||||
preset = root / "vehicles" / "denza_n7" / "office.yaml"
|
||||
preset.parent.mkdir(parents=True)
|
||||
preset.write_text(
|
||||
"""
|
||||
id: office
|
||||
name: 办公模式
|
||||
icon: briefcase
|
||||
scope:
|
||||
vehicle_ids: [denza_n7]
|
||||
operations:
|
||||
- domain: climate
|
||||
capability: climate.set_temperature
|
||||
target_scope: passenger
|
||||
parameters:
|
||||
temperature_c: 24
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
registry = ModeProfileRegistry(root, user_root=user_root)
|
||||
flow = {
|
||||
"schema": "cognitive-agent-mode-command-flow/v1",
|
||||
"source_request_id": "req-mode-editor-001",
|
||||
"plans": {
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"message": "Native_0x1DE",
|
||||
"signals": {
|
||||
"frame_id": "0x1DE",
|
||||
"data_hex": "00 00 00 00 18 00 00 00",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
registry.save_mode(
|
||||
"专注办公模式",
|
||||
[
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "passenger",
|
||||
"parameters": {"temperature_c": 24},
|
||||
}
|
||||
],
|
||||
mode_id="office",
|
||||
vehicle_id="denza_n7",
|
||||
icon="briefcase",
|
||||
instruction="副驾空调设置为24度",
|
||||
command_flow=flow,
|
||||
)
|
||||
|
||||
reloaded = ModeProfileRegistry(root, user_root=user_root)
|
||||
mode = next(
|
||||
item
|
||||
for item in reloaded.public_modes("denza_n7")
|
||||
if item["id"] == "office"
|
||||
)
|
||||
self.assertEqual(mode["name"], "专注办公模式")
|
||||
self.assertEqual(mode["origin"], "customized")
|
||||
self.assertTrue(mode["preset_override"])
|
||||
self.assertEqual(mode["instruction"], "副驾空调设置为24度")
|
||||
self.assertEqual(
|
||||
mode["command_flow"]["source_request_id"],
|
||||
"req-mode-editor-001",
|
||||
)
|
||||
self.assertTrue(
|
||||
(user_root / "vehicles" / "denza_n7" / "user" / "office.yaml").is_file()
|
||||
)
|
||||
|
||||
def test_session_overlay_ignores_stale_repository_user_modes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "profiles"
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.native_can_runtime import NativeCanRuntime
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
from cognitive_agent.infrastructure.can.gateway import CanFrame
|
||||
from cognitive_agent.infrastructure.can.hardware import (
|
||||
HardwareSafetyError,
|
||||
@@ -18,6 +22,9 @@ from cognitive_agent.infrastructure.can.mqtt_transport import (
|
||||
)
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class _PublishResult:
|
||||
rc = 0
|
||||
|
||||
@@ -72,7 +79,7 @@ def _settings():
|
||||
"host": "127.0.0.1",
|
||||
"port": 1883,
|
||||
"condition_topic": "n7/n7-headunit/can/state",
|
||||
"control_topic": "n7/can_control",
|
||||
"control_topic": "n7/n7-headunit/can/command",
|
||||
"qos": 1,
|
||||
"keepalive_s": 30,
|
||||
"username": None,
|
||||
@@ -86,12 +93,32 @@ def _settings():
|
||||
"state_configured": True,
|
||||
"backend": "mqtt",
|
||||
"condition_topic": "n7/n7-headunit/can/state",
|
||||
"control_topic": "n7/can_control",
|
||||
"control_topic": "n7/n7-headunit/can/command",
|
||||
}
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
class MqttCanPayloadTests(unittest.TestCase):
|
||||
def test_default_topics_and_public_contracts_match_headunit(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
settings = VehicleMqttSettings.from_env()
|
||||
info = settings.public_info()
|
||||
self.assertEqual(settings.condition_topic, "n7/n7-headunit/can/state")
|
||||
self.assertEqual(settings.control_topic, "n7/n7-headunit/can/command")
|
||||
self.assertFalse(settings.approval_required)
|
||||
self.assertEqual(info["state_contract"], "n7-can-signals/v1")
|
||||
self.assertEqual(info["control_contract"], "n7-headunit-can-command/v1")
|
||||
|
||||
def test_explicit_shared_approval_policy_still_locks_mqtt_control(self) -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "true"},
|
||||
clear=True,
|
||||
):
|
||||
settings = VehicleMqttSettings.from_env()
|
||||
self.assertTrue(settings.approval_required)
|
||||
self.assertFalse(settings.configured)
|
||||
|
||||
def test_accepts_one_canonical_frame_and_common_receive_aliases(self) -> None:
|
||||
canonical = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
@@ -172,8 +199,8 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
self.assertEqual(frame.arbitration_id, 0x42E)
|
||||
self.assertTrue(frame.is_fd)
|
||||
|
||||
def test_accepts_nested_can0_seat_signals_with_can0_default(self) -> None:
|
||||
frame = parse_mqtt_can_payload(
|
||||
def test_ignores_legacy_can0_state_outside_seat_ota_wire_protocol(self) -> None:
|
||||
frames = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
@@ -188,10 +215,8 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
)
|
||||
)[0]
|
||||
self.assertEqual(frame.arbitration_id, 0x302)
|
||||
self.assertEqual(frame.channel, 0)
|
||||
self.assertFalse(frame.is_fd)
|
||||
)
|
||||
self.assertEqual(frames, [])
|
||||
|
||||
def test_translates_seat_ota_info_responses_into_four_position_states(self) -> None:
|
||||
values = {
|
||||
@@ -199,10 +224,6 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
2: (373 << 16) | 266,
|
||||
3: (917 << 16) | 451,
|
||||
4: (647 << 16) | 575,
|
||||
5: 0x11,
|
||||
6: 0x11,
|
||||
7: 0x11,
|
||||
8: 0x11,
|
||||
}
|
||||
signals = []
|
||||
for selector, value in values.items():
|
||||
@@ -243,7 +264,7 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
"fd",
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x302",
|
||||
"canId": "0x6A0",
|
||||
"frameType": "fd",
|
||||
"bytes": [0] * 8,
|
||||
},
|
||||
@@ -252,7 +273,7 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
"brs",
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x302",
|
||||
"canId": "0x6A2",
|
||||
"frameType": "classic",
|
||||
"brs": True,
|
||||
"bytes": [0] * 8,
|
||||
@@ -262,7 +283,7 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
"dlc",
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x302",
|
||||
"canId": "0x6A2",
|
||||
"frameType": "classic",
|
||||
"bytes": [0] * 7,
|
||||
},
|
||||
@@ -292,7 +313,7 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
[(frame.channel, frame.arbitration_id) for frame in frames],
|
||||
[(0, 0x302), (1, 0x342)],
|
||||
[(1, 0x342)],
|
||||
)
|
||||
|
||||
def test_rejects_decoded_state_invalid_channels_and_channel_conflicts(self) -> None:
|
||||
@@ -322,7 +343,7 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
host="127.0.0.1",
|
||||
port=1883,
|
||||
condition_topic="n7/n7-headunit/can/state",
|
||||
control_topic="n7/can_control",
|
||||
control_topic="n7/n7-headunit/can/command",
|
||||
qos=1,
|
||||
keepalive_s=30,
|
||||
username=None,
|
||||
@@ -377,6 +398,12 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
for state in snapshot.feedback_states
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
state["source"] == "wireless_mqtt_ch1"
|
||||
for state in snapshot.feedback_states
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.subscriptions,
|
||||
[("n7/n7-headunit/can/state", 1)],
|
||||
@@ -404,6 +431,53 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
self.assertLess(time.perf_counter() - started, 0.05)
|
||||
self.assertEqual(snapshot.observed_ids, frozenset({0x388}))
|
||||
|
||||
def test_vehicle_envelope_four_frames_decode_from_raw_can1_bytes(self) -> None:
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"sequence": 335,
|
||||
"signals": [
|
||||
{"channel": "can1", "canId": frame_id, "frameType": "fd",
|
||||
"brs": True, "data": data}
|
||||
for frame_id, data in (
|
||||
("0x388", "00 00 00 02 01 00 59 00"),
|
||||
("0x41A", "00 5C 85 08 20 42 C1 FD"),
|
||||
("0x42E", "11 64 32 03 90 02 00 C6"),
|
||||
("0x3FF", "5B 09 FF 0F 32 61 04 12"),
|
||||
)
|
||||
],
|
||||
# This summary is deliberately inconsistent. Only the raw CAN
|
||||
# bytes above are allowed to update authoritative UI state.
|
||||
"can1Vehicle": {"parsed": {"speedKph": 123, "gear": 4}},
|
||||
},
|
||||
)
|
||||
|
||||
snapshot = self.transport.snapshot()
|
||||
self.assertEqual(
|
||||
snapshot.observed_ids,
|
||||
frozenset({0x388, 0x3FF, 0x41A, 0x42E}),
|
||||
)
|
||||
by_id = {state["frame_id"]: state for state in snapshot.feedback_states}
|
||||
self.assertEqual(
|
||||
by_id["0x388"]["domain_state"]["window"]["positions"]["rear_left"]["position_pct"],
|
||||
2,
|
||||
)
|
||||
self.assertEqual(
|
||||
by_id["0x41A"]["domain_state"]["roof"]["shade_position_pct"],
|
||||
92,
|
||||
)
|
||||
self.assertIsNone(
|
||||
by_id["0x42E"]["domain_state"]["ambient_light"]["front_color_index"]
|
||||
)
|
||||
self.assertEqual(
|
||||
by_id["0x3FF"]["domain_state"]["interior_light"]["ambient_illuminance_lux"],
|
||||
510,
|
||||
)
|
||||
self.assertTrue(
|
||||
all(state["source"] == "wireless_mqtt_ch1" for state in by_id.values())
|
||||
)
|
||||
|
||||
def test_stale_safety_signal_cannot_enter_snapshot_or_executor_bus(self) -> None:
|
||||
bus = self.transport.bus(channel=1)
|
||||
self.client.emit(
|
||||
@@ -470,8 +544,9 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
self.assertGreater(verification["cache_age_ms"], 50_000)
|
||||
self.assertIsNone(bus.receive(0, include_fd=True))
|
||||
|
||||
def test_bus_publishes_only_canonical_frame_contract(self) -> None:
|
||||
def test_bus_publishes_only_headunit_command_contract(self) -> None:
|
||||
bus = self.transport.bus()
|
||||
bus.set_debug_command("shade position 20")
|
||||
sent = bus.send(
|
||||
0x431,
|
||||
bytes.fromhex("10 00 00 00 00 00 00 00"),
|
||||
@@ -482,25 +557,253 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
self.assertEqual(len(self.client.published), 1)
|
||||
topic, raw_payload, qos, retain = self.client.published[0]
|
||||
payload = json.loads(raw_payload)
|
||||
self.assertEqual(topic, "n7/can_control")
|
||||
self.assertEqual(topic, "n7/n7-headunit/can/command")
|
||||
self.assertEqual(qos, 1)
|
||||
self.assertFalse(retain)
|
||||
self.assertEqual(payload["schema"], MQTT_CAN_SCHEMA)
|
||||
self.assertEqual(payload["arbitration_id"], 0x431)
|
||||
self.assertEqual(payload["data_hex"], "10 00 00 00 00 00 00 00")
|
||||
self.assertTrue(payload["is_fd"])
|
||||
self.assertTrue(payload["bitrate_switch"])
|
||||
self.assertRegex(payload["requestId"], r"^cognitive-agent-[0-9a-f]{32}$")
|
||||
self.assertEqual(payload["command"], "shade position 20")
|
||||
self.assertEqual(payload["channel"], "can1")
|
||||
self.assertEqual(payload["canId"], "0x431")
|
||||
self.assertEqual(payload["frameType"], "fd")
|
||||
self.assertTrue(payload["brs"])
|
||||
self.assertFalse(payload["extended"])
|
||||
self.assertFalse(payload["remote"])
|
||||
self.assertEqual(payload["data"], "10 00 00 00 00 00 00 00")
|
||||
self.assertEqual(payload["repeat"], 1)
|
||||
self.assertEqual(payload["intervalMs"], 0)
|
||||
self.assertEqual(payload["responseTimeoutMs"], 100)
|
||||
publication = bus.published_messages[0]
|
||||
self.assertEqual(publication["topic"], topic)
|
||||
self.assertEqual(publication["qos"], qos)
|
||||
self.assertEqual(publication["retain"], retain)
|
||||
self.assertEqual(publication["payload"], payload)
|
||||
self.assertTrue(
|
||||
{
|
||||
"schema",
|
||||
"arbitration_id",
|
||||
"frame_id",
|
||||
"data_hex",
|
||||
"is_fd",
|
||||
"bitrate_switch",
|
||||
}.isdisjoint(payload)
|
||||
)
|
||||
bus.send(
|
||||
0x431,
|
||||
bytes.fromhex("10 00 00 00 00 00 00 00"),
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
)
|
||||
second_payload = json.loads(self.client.published[1][1])
|
||||
self.assertNotEqual(payload["requestId"], second_payload["requestId"])
|
||||
with self.assertRaisesRegex(Exception, "FD command requires BRS"):
|
||||
bus.send(0x431, bytes(8), is_fd=True, bitrate_switch=False)
|
||||
with self.assertRaisesRegex(Exception, "classic CAN command cannot enable BRS"):
|
||||
bus.send(0x431, bytes(8), is_fd=False, bitrate_switch=True)
|
||||
with self.assertRaisesRegex(Exception, "approved CAN1 control set"):
|
||||
bus.send(0x555, bytes(8), is_fd=True)
|
||||
self.assertEqual(len(self.client.published), 1)
|
||||
with self.assertRaisesRegex(Exception, "at most 256"):
|
||||
bus.set_debug_command("x" * 257)
|
||||
self.assertEqual(len(self.client.published), 2)
|
||||
self.assertEqual(len(bus.published_messages), 2)
|
||||
|
||||
def test_can1_whitelist_publishes_rear_lock_candidate_with_debug_command(self) -> None:
|
||||
bus = self.transport.bus(channel=1)
|
||||
bus.set_debug_command("door rear lock")
|
||||
sent = bus.send(0x32B, bytes.fromhex("0A 40 00 00 00 00 00 00"))
|
||||
|
||||
self.assertEqual(sent.arbitration_id, 0x32B)
|
||||
self.assertFalse(sent.is_fd)
|
||||
topic, raw_payload, qos, retain = self.client.published[-1]
|
||||
payload = json.loads(raw_payload)
|
||||
self.assertEqual(topic, "n7/n7-headunit/can/command")
|
||||
self.assertEqual(qos, 1)
|
||||
self.assertFalse(retain)
|
||||
self.assertEqual(payload["command"], "door rear lock")
|
||||
self.assertEqual(payload["channel"], "can1")
|
||||
self.assertEqual(payload["canId"], "0x32B")
|
||||
self.assertEqual(payload["frameType"], "classic")
|
||||
self.assertFalse(payload["brs"])
|
||||
self.assertEqual(payload["data"], "0A 40 00 00 00 00 00 00")
|
||||
|
||||
def test_wireless_whitelisted_climate_plan_publishes_without_safety_or_feedback_frames(
|
||||
self,
|
||||
) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(feedback_timeout_ms=1)
|
||||
actions = [
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target": "driver",
|
||||
"operation": "set",
|
||||
"parameters": {"temperature_c": 24},
|
||||
},
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_fan_level",
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"level": 2},
|
||||
},
|
||||
]
|
||||
runtime = NativeCanRuntime(REPOSITORY_ROOT)
|
||||
config = runtime.build_function_config(
|
||||
"climate", actions, "req-wireless-climate", "local_rules"
|
||||
)
|
||||
plan = runtime.compile(config)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cognitive_agent.application.seat_service.observe_vehicle_safety",
|
||||
side_effect=AssertionError(
|
||||
"wireless whitelist publish must not require 0x342/0x4EB"
|
||||
),
|
||||
),
|
||||
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
|
||||
):
|
||||
reports = service._execute_orchestrated_reports(
|
||||
{"native_climate": config},
|
||||
{"native_climate": plan},
|
||||
{"native_climate"},
|
||||
{},
|
||||
hardware_request=True,
|
||||
simulated_hardware_request=False,
|
||||
simulation_fault="normal",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
report = reports["native_climate"]
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
report["spec"]["vehicle_safety_preflight"]["policy"],
|
||||
"mqtt_whitelist_publish",
|
||||
)
|
||||
self.assertEqual(len(self.client.published), 6)
|
||||
payloads = [json.loads(item[1]) for item in self.client.published]
|
||||
self.assertTrue(
|
||||
all(
|
||||
topic == "n7/n7-headunit/can/command"
|
||||
for topic, _payload, _qos, _retain in self.client.published
|
||||
)
|
||||
)
|
||||
self.assertTrue(all(payload["channel"] == "can1" for payload in payloads))
|
||||
self.assertTrue(all(payload["canId"] == "0x1DE" for payload in payloads))
|
||||
self.assertEqual(
|
||||
[payload["command"] for payload in payloads],
|
||||
["ac temp driver 24"] * 3 + ["ac fan 2"] * 3,
|
||||
)
|
||||
self.assertEqual(
|
||||
[payload["data"] for payload in payloads],
|
||||
[
|
||||
"00 00 00 00 18 00 00 00",
|
||||
"00 00 00 00 18 00 00 00",
|
||||
"00 00 00 00 18 00 00 00",
|
||||
"20 00 00 00 00 00 00 00",
|
||||
"20 00 00 00 00 00 00 00",
|
||||
"20 00 00 00 00 00 00 00",
|
||||
],
|
||||
)
|
||||
for result in report["spec"]["results"]:
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(result["telemetry"]["delivery_status"], "published")
|
||||
self.assertFalse(result["telemetry"]["feedback_required"])
|
||||
self.assertFalse(result["telemetry"]["physical_effect_confirmed"])
|
||||
self.assertEqual(len(result["telemetry"]["mqtt_messages"]), 3)
|
||||
self.assertTrue(
|
||||
all(
|
||||
message["topic"] == "n7/n7-headunit/can/command"
|
||||
for message in result["telemetry"]["mqtt_messages"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_model_added_nonwhitelisted_action_does_not_block_whitelisted_publish(
|
||||
self,
|
||||
) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(
|
||||
enabled=True,
|
||||
configured=True,
|
||||
feedback_timeout_ms=1,
|
||||
authorize=lambda _token: None,
|
||||
)
|
||||
model_output = {
|
||||
"detected_domains": ["climate", "ambient_light"],
|
||||
"requests": [
|
||||
{
|
||||
"domain": "ambient_light",
|
||||
"capability": "ambient_light.set_mode",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": [{"name": "mode", "value": "cinema"}],
|
||||
"reason": "模型根据观影状态补充了氛围灯模式。",
|
||||
},
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "driver",
|
||||
"operation": "set",
|
||||
"parameters": [{"name": "temperature_c", "value": 24}],
|
||||
"reason": "用户明确指定温度。",
|
||||
},
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_fan_level",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": [{"name": "level", "value": 2}],
|
||||
"reason": "用户明确指定风量。",
|
||||
},
|
||||
],
|
||||
"ambiguities": [],
|
||||
"decision_summary": ["执行用户明确指定的观影空调设置。"],
|
||||
"confidence": 0.96,
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
service.artifact_root = Path(temporary_directory)
|
||||
service.openai.api_key = "test-key"
|
||||
with (
|
||||
patch.object(service.openai, "interpret", return_value=model_output),
|
||||
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
|
||||
):
|
||||
result = service.process(
|
||||
"进入观影状态:空调24度、风量2档,",
|
||||
provider="openai",
|
||||
conversation_id="conv-wireless-movie-climate",
|
||||
execution_mode="hardware",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "partially_succeeded")
|
||||
self.assertEqual(
|
||||
[item["capability"] for item in result["deferred_requests"]],
|
||||
["ambient_light.set_mode"],
|
||||
)
|
||||
self.assertEqual(
|
||||
result["deferred_requests"][0]["rejection_code"],
|
||||
"WIRELESS_MQTT_CONTROL_NOT_WHITELISTED",
|
||||
)
|
||||
self.assertEqual(len(self.client.published), 6)
|
||||
self.assertTrue(
|
||||
all(
|
||||
topic == "n7/n7-headunit/can/command"
|
||||
and json.loads(payload)["canId"] == "0x1DE"
|
||||
for topic, payload, _qos, _retain in self.client.published
|
||||
)
|
||||
)
|
||||
|
||||
def test_can0_bus_publishes_only_classic_passenger_seat_control(self) -> None:
|
||||
bus = self.transport.bus(channel=0)
|
||||
sent = bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"))
|
||||
payload = json.loads(self.client.published[-1][1])
|
||||
self.assertEqual(sent.channel, 0)
|
||||
self.assertEqual(payload["channel"], 0)
|
||||
self.assertFalse(payload["is_fd"])
|
||||
self.assertEqual(payload["channel"], "can0")
|
||||
self.assertEqual(payload["canId"], "0x6A0")
|
||||
self.assertEqual(payload["command"], "")
|
||||
self.assertEqual(payload["frameType"], "classic")
|
||||
self.assertFalse(payload["brs"])
|
||||
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
|
||||
bus.send(0x431, bytes(8))
|
||||
with self.assertRaisesRegex(Exception, "requires classic CAN"):
|
||||
@@ -524,7 +827,10 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"signals": [
|
||||
{"channel": "can0", "canId": "0x302", "bytes": [0] * 8},
|
||||
{"channel": "can0", "canId": "0x6A0", "bytes":
|
||||
[0xC7, 0x21, 0x60, 1, 0, 0, 0, 0]},
|
||||
{"channel": "can0", "canId": "0x6A2", "bytes":
|
||||
[0x7C, 0x21, 0x60, 0, 0xE0, 0x01, 0xE0, 0x01]},
|
||||
{
|
||||
"channel": "can1",
|
||||
"canId": "0x342",
|
||||
@@ -534,6 +840,7 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
]
|
||||
},
|
||||
)
|
||||
self.assertEqual(can0.receive(0).arbitration_id, 0x6A2)
|
||||
self.assertEqual(can0.receive(0).arbitration_id, 0x302)
|
||||
self.assertEqual(can1.receive(0, include_fd=True).arbitration_id, 0x342)
|
||||
self.assertIsNone(can0.receive(0))
|
||||
@@ -546,10 +853,6 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
2: (373 << 16) | 266,
|
||||
3: (917 << 16) | 451,
|
||||
4: (647 << 16) | 575,
|
||||
5: 0x11,
|
||||
6: 0x11,
|
||||
7: 0x11,
|
||||
8: 0x11,
|
||||
}.items():
|
||||
sequence = 0x40 + selector
|
||||
signals.extend(
|
||||
|
||||
@@ -8,6 +8,7 @@ from cognitive_agent.application.mqtt_ota_seat_executor import (
|
||||
MqttOtaSeatExecutor,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.gateway import CanFrame
|
||||
from seat_domain import parse_local_intent
|
||||
|
||||
|
||||
def _axis_frame(axis: int, value_x100: int, *, valid: bool = True) -> CanFrame:
|
||||
@@ -34,8 +35,12 @@ class _FakeOtaBus:
|
||||
def __init__(self, initial_frames: list[CanFrame]) -> None:
|
||||
self.frames = deque(initial_frames)
|
||||
self.sent: list[CanFrame] = []
|
||||
self.debug_commands: list[str] = []
|
||||
self.closed = False
|
||||
|
||||
def set_debug_command(self, command: str) -> None:
|
||||
self.debug_commands.append(command)
|
||||
|
||||
def send(
|
||||
self,
|
||||
arbitration_id: int,
|
||||
@@ -110,6 +115,14 @@ def _function_config(capability: str, parameters: dict[str, object]) -> dict[str
|
||||
|
||||
|
||||
class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
def test_seat_height_language_maps_to_internal_axis_one_capability(self) -> None:
|
||||
parsed = parse_local_intent("副驾座椅高度调到60%")
|
||||
|
||||
self.assertEqual(parsed["ambiguities"], [])
|
||||
self.assertEqual(parsed["actions"][0]["axis"], "cushion_tilt")
|
||||
self.assertEqual(parsed["actions"][0]["position_pct"], 60)
|
||||
self.assertIn("目标:副驾座椅高度位置60%。", parsed["decision_summary"])
|
||||
|
||||
def test_absolute_move_uses_runtime_move_and_fresh_position_confirmation(self) -> None:
|
||||
bus = _FakeOtaBus([_axis_frame(0, 4_000)])
|
||||
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
|
||||
@@ -146,6 +159,10 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
self.assertEqual(bus.sent[0].data[2], 0x64)
|
||||
self.assertEqual(bus.sent[0].data[3], 0)
|
||||
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 500)
|
||||
self.assertEqual(
|
||||
bus.debug_commands,
|
||||
["seat passenger longitudinal position 50"],
|
||||
)
|
||||
self.assertEqual(
|
||||
report["spec"]["results"][0]["telemetry"]["effective_position_pct"],
|
||||
50,
|
||||
@@ -213,6 +230,56 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
self.assertIn("does not provide wireless thermal control", report["spec"]["results"][0]["message"])
|
||||
self.assertEqual(bus.sent, [])
|
||||
|
||||
def test_dynamic_semantic_modes_use_seat_ota_firmware_selectors(self) -> None:
|
||||
for semantic_mode, firmware_selector, mode_name in (
|
||||
(1, 2, "木马模式"),
|
||||
(2, 1, "动态摇篮模式"),
|
||||
):
|
||||
with self.subTest(semantic_mode=semantic_mode):
|
||||
bus = _FakeOtaBus([])
|
||||
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
|
||||
_StatusExecutor(),
|
||||
lambda: bus,
|
||||
)
|
||||
plan = {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "operation-1",
|
||||
"message": "T2G_Seat_Move_Command_300",
|
||||
"signals": {"Opcode": 0x0E, "Motor_Selector": 0},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
report = executor.execute(
|
||||
plan,
|
||||
_function_config(
|
||||
"seat.start_dynamic_mode",
|
||||
{"mode_id": semantic_mode},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(bus.sent[0].data[2], 0x66)
|
||||
self.assertEqual(bus.sent[0].data[3], firmware_selector)
|
||||
self.assertEqual(
|
||||
bus.debug_commands,
|
||||
[
|
||||
"seat passenger dynamic start "
|
||||
+ ("rocking-horse" if semantic_mode == 1 else "cradle")
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
int.from_bytes(bus.sent[0].data[4:8], "little"),
|
||||
300,
|
||||
)
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(telemetry["dynamic_mode_id"], semantic_mode)
|
||||
self.assertEqual(telemetry["ota_dynamic_selector"], firmware_selector)
|
||||
self.assertEqual(telemetry["dynamic_mode_name"], mode_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -35,6 +35,13 @@ class NativeCanFeedbackTests(unittest.TestCase):
|
||||
self.assertTrue(feedback_matches("climate_temperature:driver:24", feedback))
|
||||
self.assertFalse(feedback_matches("climate_temperature:driver:25", feedback))
|
||||
|
||||
def test_climate_feedback_decodes_front_defrost(self) -> None:
|
||||
decoded = decode_feedback(
|
||||
self.frame(0x404, "43A9116c1818a2dd")
|
||||
)["domain_state"]["climate"]
|
||||
|
||||
self.assertEqual(decoded["front_defrost"], "on")
|
||||
|
||||
def test_window_position_match_uses_authoritative_0x388(self) -> None:
|
||||
feedback = self.frame(0x388, "0000320000005500")
|
||||
self.assertTrue(feedback_matches("window_position:front_left:50", feedback))
|
||||
@@ -90,6 +97,27 @@ class NativeCanFeedbackTests(unittest.TestCase):
|
||||
self.assertTrue(feedback_matches("driver_seat_thermal:heating:low", feedback))
|
||||
self.assertFalse(feedback_matches("driver_seat_thermal:ventilation:high", feedback))
|
||||
|
||||
def test_driver_seat_feedback_supports_all_three_thermal_levels(self) -> None:
|
||||
driver = decode_feedback(
|
||||
self.frame(0x4FA, "1C00000000010000")
|
||||
)["actuator_state"]["driver"]
|
||||
|
||||
self.assertEqual(driver["ventilation"], {"state": "on", "level": 3})
|
||||
self.assertEqual(driver["heating"], {"state": "on", "level": 2})
|
||||
|
||||
def test_38a_matrix_decodes_front_lighting_feedback(self) -> None:
|
||||
lights = decode_feedback(
|
||||
self.frame(0x38A, "1969000000000000")
|
||||
)["domain_state"]["front_light"]
|
||||
|
||||
self.assertEqual(lights["position_light"]["state"], "on")
|
||||
self.assertEqual(lights["low_beam"]["state"], "off")
|
||||
self.assertEqual(lights["high_beam"]["state"], "on")
|
||||
self.assertEqual(lights["fog_light"]["state"], "on")
|
||||
self.assertEqual(lights["brake_light"]["state"], "flashing")
|
||||
self.assertEqual(lights["reverse_light"]["state"], "off")
|
||||
self.assertEqual(lights["all_weather_light"]["state"], "on")
|
||||
|
||||
def test_driver_position_feedback_never_claims_absolute_position(self) -> None:
|
||||
decoded = decode_feedback(
|
||||
self.frame(0x494, "151515058c28203c")
|
||||
|
||||
@@ -157,7 +157,7 @@ class NativeStateSyncTests(unittest.TestCase):
|
||||
self.assertEqual(climate["cycle_mode"], "internal")
|
||||
self.assertEqual(climate["feedback_source"], "physical_can_ch1")
|
||||
self.assertEqual(state["domain_devices"]["roof"]["shade_position_pct"], 2)
|
||||
self.assertEqual(state["domain_devices"]["ambient_light"]["brightness_pct"], 100)
|
||||
self.assertEqual(state["domain_devices"]["ambient_light"]["brightness_pct"], 60)
|
||||
self.assertEqual(
|
||||
state["domain_devices"]["door"]["locks"]["rear_left"]["feedback_source"],
|
||||
"physical_can_ch1",
|
||||
@@ -190,6 +190,58 @@ class NativeStateSyncTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_verified_wireless_ch1_feedback_updates_realtime_state(self) -> None:
|
||||
state = self.store.synchronize_physical_ch1_feedback(
|
||||
"conv-native-sync",
|
||||
[{
|
||||
"source": "wireless_mqtt_ch1",
|
||||
"channel": 1,
|
||||
"frame_id": "0x41A",
|
||||
"feedback_frame_id": "0x41A",
|
||||
"valid": True,
|
||||
"verification": {
|
||||
"authoritative": True,
|
||||
"channel": 1,
|
||||
"simulated": False,
|
||||
"read_only": True,
|
||||
"transport": "wireless_mqtt",
|
||||
"frame_id": "0x41A",
|
||||
"payload_size": 8,
|
||||
},
|
||||
"domain_state": {
|
||||
"roof": {"shade_position_pct": 92, "motion_state": "stopped"}
|
||||
},
|
||||
}],
|
||||
)
|
||||
|
||||
roof = state["domain_devices"]["roof"]
|
||||
self.assertEqual(roof["shade_position_pct"], 92)
|
||||
self.assertEqual(roof["feedback_source"], "wireless_mqtt_ch1")
|
||||
self.assertIn("wireless_mqtt_ch1_state_updated_at", state)
|
||||
|
||||
def test_wireless_source_without_mqtt_transport_verification_is_ignored(self) -> None:
|
||||
before = self.store.snapshot("conv-native-sync")
|
||||
after = self.store.synchronize_physical_ch1_feedback(
|
||||
"conv-native-sync",
|
||||
[{
|
||||
"source": "wireless_mqtt_ch1",
|
||||
"channel": 1,
|
||||
"frame_id": "0x41A",
|
||||
"feedback_frame_id": "0x41A",
|
||||
"valid": True,
|
||||
"verification": {
|
||||
"authoritative": True,
|
||||
"channel": 1,
|
||||
"simulated": False,
|
||||
"read_only": True,
|
||||
"frame_id": "0x41A",
|
||||
"payload_size": 8,
|
||||
},
|
||||
"domain_state": {"roof": {"shade_position_pct": 92}},
|
||||
}],
|
||||
)
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_verified_read_only_ch1_feedback_updates_only_driver(self) -> None:
|
||||
state = self.store.synchronize_physical_ch1_feedback(
|
||||
"conv-native-sync",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -35,6 +38,37 @@ def test_frontend_only_exposes_user_orchestration_and_extension() -> None:
|
||||
assert "v2RoleSelect" not in console
|
||||
|
||||
|
||||
def test_retired_layout_is_removed_instead_of_permanently_hidden() -> None:
|
||||
html = source("index.html")
|
||||
app = source("app.js")
|
||||
console = source("console-v2.js")
|
||||
styles = source("console-v2.css")
|
||||
|
||||
for retired_markup in (
|
||||
'class="topbar"',
|
||||
'class="example-group"',
|
||||
'id="safetyStrip"',
|
||||
'class="pipeline"',
|
||||
'id="gestureBackdrop"',
|
||||
):
|
||||
assert retired_markup not in html
|
||||
|
||||
for retired_runtime_hook in (
|
||||
"v2-legacy-parking",
|
||||
"v2-legacy-hidden",
|
||||
"#connectionBadge",
|
||||
"#modelBadge",
|
||||
"#hardwareBadge",
|
||||
"#executionBadge",
|
||||
"#logoutButton",
|
||||
"#gestureToggleButton",
|
||||
"#pwaBadge",
|
||||
):
|
||||
assert retired_runtime_hook not in app
|
||||
assert retired_runtime_hook not in console
|
||||
assert retired_runtime_hook not in styles
|
||||
|
||||
|
||||
def test_extension_pet_and_transient_vehicle_state_restart_contract() -> None:
|
||||
extension = source("self-extension.js")
|
||||
styles = source("self-extension.css")
|
||||
@@ -138,6 +172,278 @@ def test_vehicle_device_status_cards_prioritize_seats_and_expose_manual_controls
|
||||
assert ".v2-device-control-apply" in styles
|
||||
|
||||
|
||||
def test_passenger_can0_status_ui_uses_only_seat_ota_four_axis_contract() -> None:
|
||||
app = source("app.js")
|
||||
console = source("console-v2.js")
|
||||
|
||||
assert 'new Set(["longitudinal", "height", "backrest_recline", "legrest_height"])' in app
|
||||
assert 'if (thermalRow) thermalRow.hidden = seat === "passenger";' in app
|
||||
assert 'height: seatHeight,' in app
|
||||
assert '"seat.set_cushion_tilt": "调节座椅高度"' in app
|
||||
assert 'rows: () => [["前后", seatAxisValue("passenger", "longitudinal")], ["高度", seatAxisValue("passenger", "cushion_tilt")]' in console
|
||||
assert 'rangeControl("cushion_tilt", "高度"' in console
|
||||
assert 'rangeControl("legrest_extension"' not in console
|
||||
|
||||
|
||||
def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
|
||||
console = source("console-v2.js")
|
||||
styles = source("console-v2.css")
|
||||
|
||||
assert 'make("details", "v2-can-command-panel"' in console
|
||||
assert 'canCommandPanel.id = "v2CanCommandPanel"' in console
|
||||
assert 'id="v2CanCommandCount">0</strong>' in console
|
||||
assert 'id="v2CanCommandList"' in console
|
||||
assert "canCommandPanel.open" not in console
|
||||
assert "function parsedCanCommandForOperation(operation = {})" in console
|
||||
assert "function parsedCanCommands(result = {})" in console
|
||||
assert "getParsedCanCommands: (result) => parsedCanCommands(result)" in console
|
||||
assert "const configurations = result.function_configs || {};" in console
|
||||
assert "const plans = result.command_plans || {};" in console
|
||||
assert 'const command = (category, text, channel = "CAN1", frame = "")' in console
|
||||
assert 'data-can-channel="${escapeHtml(item.channel)}"' in console
|
||||
assert 'class="v2-can-command-channel"' in console
|
||||
assert 'window.addEventListener("cognitive-agent:result"' in console
|
||||
assert '$("#requestForm")?.addEventListener("submit"' in console
|
||||
|
||||
for command_contract in (
|
||||
"door rear ${action}",
|
||||
"door vehicle ${lockState}",
|
||||
"window ${target} ${motion}",
|
||||
"window ${target} position ${position}",
|
||||
"ac ${onOff}",
|
||||
"ac fan ${level}",
|
||||
"ac mode ${mode}",
|
||||
"ac temp ${target} ${temperature}",
|
||||
"ac control ${mode}",
|
||||
"ac cycle ${mode}",
|
||||
"outlet ${target} percent ${horizontal} ${vertical}",
|
||||
"outlet ${target} position ${horizontal} ${vertical}",
|
||||
"seat driver ${feature} ${setting}",
|
||||
"seat driver massage strength ${strength}",
|
||||
"seat driver ${axis} ${direction} ${duration}",
|
||||
"seat driver memory recall ${position}",
|
||||
"seat driver position ${position}",
|
||||
"light indoor ${onOff}",
|
||||
"light reading all ${onOff}",
|
||||
"light ambient color ${color}",
|
||||
"light ambient brightness ${level}",
|
||||
"shade position ${position}",
|
||||
"seat passenger ${axis[1]} position ${position}",
|
||||
"seat passenger dynamic start ${dynamic.name}",
|
||||
"seat passenger dynamic stop",
|
||||
"otaCan0Frame(0x64, axis[2], targetPermille)",
|
||||
"otaCan0Frame(0x66, dynamic.selector, 300)",
|
||||
"otaCan0Frame(0x63, 0xFF, 0)",
|
||||
):
|
||||
assert command_contract in console
|
||||
|
||||
assert ".v2-can-command-panel[open]" in styles
|
||||
assert ".v2-can-command-list li code" in styles
|
||||
assert ".v2-can-command-channel" in styles
|
||||
assert 'li[data-can-channel="CAN0"]' in styles
|
||||
assert ".v2-sidebar-collapsed .v2-can-command-panel" in styles
|
||||
|
||||
|
||||
def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands() -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
return
|
||||
console = source("console-v2.js")
|
||||
start = console.index(" const normalizedCanTarget")
|
||||
end = console.index("\n\n function renderParsedCanCommands", start)
|
||||
helpers = console[start:end]
|
||||
sample = {
|
||||
"function_configs": {
|
||||
"seat": {
|
||||
"spec": {
|
||||
"operations": [
|
||||
{
|
||||
"capability": "seat.set_backrest_recline",
|
||||
"target": {"zone": "front_right"},
|
||||
"parameters": {"position_pct": 36, "angle_deg": 120},
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"operations": [
|
||||
{
|
||||
"capability": "climate.set_temperature",
|
||||
"target": {"zone": "driver"},
|
||||
"parameters": {"temperature_c": 24},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
"command_plans": {
|
||||
"seat": {"spec": {"commands": [{}]}},
|
||||
"native_climate": {"spec": {"commands": [{}]}},
|
||||
},
|
||||
}
|
||||
script = (
|
||||
f"{helpers}\n"
|
||||
f"const sample = {json.dumps(sample)};\n"
|
||||
"process.stdout.write(JSON.stringify(parsedCanCommands(sample)));"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[node, "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert json.loads(completed.stdout) == [
|
||||
{
|
||||
"channel": "CAN0",
|
||||
"category": "副驾座椅靠背调节",
|
||||
"command": "seat passenger backrest position 36",
|
||||
"frame": "0x6A0 · C7 <SEQ> 64 02 68 01 00 00",
|
||||
},
|
||||
{
|
||||
"channel": "CAN1",
|
||||
"category": "空调温度",
|
||||
"command": "ac temp driver 24",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() -> None:
|
||||
app = source("app.js")
|
||||
styles = source("styles.css")
|
||||
html = source("index.html")
|
||||
|
||||
assert 'class="mode-editor-flow-preview empty"' in html
|
||||
assert "function modeEditorReadableCommands(flow)" in app
|
||||
assert "function modeEditorMessagesForOperation(flow, readable, operationRef, plan)" in app
|
||||
assert 'command?.signals?.special_handler === "driver_seat_memory_recall"' in app
|
||||
assert "Array.isArray(special?.frame_sequence)" in app
|
||||
assert "function modeEditorAppendCommandCard(preview, item, flow)" in app
|
||||
assert 'card.className = "mode-editor-command-card"' in app
|
||||
assert 'title.textContent = flow?.control_transport === "wireless_mqtt" ? "MQTT 消息序列"' in app
|
||||
assert 'source: "published"' in app
|
||||
assert 'source: flow?.control_transport === "wireless_mqtt" ? "projected" : "can"' in app
|
||||
assert "function_configs: functionConfigs" in app
|
||||
assert "execution_steps: executionSteps" in app
|
||||
|
||||
assert ".mode-editor-command-card" in styles
|
||||
assert ".mode-editor-command-meta" in styles
|
||||
assert ".mode-editor-message-sequence" in styles
|
||||
assert '.mode-editor-command-card[data-can-channel="CAN0"]' in styles
|
||||
|
||||
|
||||
def test_mode_editor_prioritizes_can_flow_display_area() -> None:
|
||||
app = source("app.js")
|
||||
html = source("index.html")
|
||||
styles = source("styles.css")
|
||||
|
||||
assert 'class="mode-editor-setup"' in html
|
||||
assert 'class="mode-editor-inputs"' in html
|
||||
assert "grid-template-rows: minmax(0, 30%) minmax(0, 1fr) auto auto;" in styles
|
||||
assert "grid-template-columns: minmax(150px, 0.32fr) minmax(0, 1fr);" in styles
|
||||
assert ".mode-editor-instruction-field textarea" in styles
|
||||
assert "resize: none;" in styles
|
||||
assert ".mode-editor-flow-preview" in styles
|
||||
assert "overflow: auto;" in styles
|
||||
assert 'id="modeEditorFlowExpand"' in html
|
||||
assert ".mode-editor.flow-expanded" in styles
|
||||
assert ".mode-editor-backdrop.flow-expanded" in styles
|
||||
assert "width: 100vw;" in styles
|
||||
assert "height: 100dvh;" in styles
|
||||
assert "grid-template-rows: minmax(0, 1fr);" in styles
|
||||
assert "touch-action: pan-y;" in styles
|
||||
assert "scrollbar-gutter: stable;" in styles
|
||||
assert "max-height: 220px;" not in styles
|
||||
assert "function setModeEditorFlowExpanded(expanded)" in app
|
||||
assert '$("#modeEditorFlowExpand").addEventListener("click", toggleModeEditorFlowExpanded);' in app
|
||||
|
||||
|
||||
def test_mode_editor_prepares_one_to_many_mqtt_message_sequences() -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
return
|
||||
app = source("app.js")
|
||||
console = source("console-v2.js")
|
||||
console_start = console.index(" const normalizedCanTarget")
|
||||
console_end = console.index("\n\n function renderParsedCanCommands", console_start)
|
||||
console_helpers = console[console_start:console_end]
|
||||
app_start = app.index("function modeEditorCommandCategory")
|
||||
app_end = app.index("\n\nfunction modeEditorAppendCommandCard", app_start)
|
||||
app_helpers = app[app_start:app_end]
|
||||
sample = {
|
||||
"control_transport": "wireless_mqtt",
|
||||
"function_configs": {
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"operations": [
|
||||
{
|
||||
"id": "temperature",
|
||||
"capability": "climate.set_temperature",
|
||||
"target": {"zone": "driver"},
|
||||
"parameters": {"temperature_c": 25},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"plans": {
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "temperature",
|
||||
"signals": {
|
||||
"frame_id": "0x1DE",
|
||||
"data_hex": "00 00 00 00 19 00 00 00",
|
||||
"is_fd": False,
|
||||
"bitrate_switch": False,
|
||||
"debug_command": "ac temp driver 25",
|
||||
},
|
||||
"send_offset_ms": 20,
|
||||
"repeat": {"count": 3, "period_ms": 100},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"execution_steps": [],
|
||||
}
|
||||
script = (
|
||||
f"{console_helpers}\n"
|
||||
"const state = {runtime:{vehicle_mqtt:{control_topic:"
|
||||
'"n7/n7-headunit/can/command",qos:1,feedback_timeout_ms:1500}}};\n'
|
||||
"const window = {CognitiveAgentConsoleV2:{getParsedCanCommands:"
|
||||
"(result)=>parsedCanCommands(result)}};\n"
|
||||
f"{app_helpers}\n"
|
||||
f"const flow = {json.dumps(sample)};\n"
|
||||
"process.stdout.write(JSON.stringify(modeEditorReadableCommands(flow)));"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[node, "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
commands = json.loads(completed.stdout)
|
||||
|
||||
assert len(commands) == 1
|
||||
assert commands[0]["channel"] == "CAN1"
|
||||
assert commands[0]["category"] == "空调温度"
|
||||
assert commands[0]["command"] == "ac temp driver 25"
|
||||
assert [message["offset_ms"] for message in commands[0]["messages"]] == [
|
||||
20,
|
||||
120,
|
||||
220,
|
||||
]
|
||||
assert all(
|
||||
message["topic"] == "n7/n7-headunit/can/command"
|
||||
and message["payload"]["canId"] == "0x1DE"
|
||||
and message["payload"]["command"] == "ac temp driver 25"
|
||||
for message in commands[0]["messages"]
|
||||
)
|
||||
|
||||
|
||||
def test_interactive_console_keeps_actions_inside_composer_and_collapsed_rails_aligned() -> None:
|
||||
console = source("console-v2.js")
|
||||
styles = source("console-v2.css")
|
||||
@@ -162,15 +468,22 @@ def test_left_sync_rail_can_collapse_and_log_aligns_with_lower_workspace() -> No
|
||||
|
||||
assert 'make("section", "v2-sync-sidebar"' in console
|
||||
assert '<div class="v2-sync-sidebar-overview">' in console
|
||||
assert '$(".v2-sync-sidebar-overview", syncSidebar)?.append(sidebarFooter);' in console
|
||||
assert "syncOverview?.append(sidebarFooter);" in console
|
||||
assert '$(".v2-sync-sidebar-heading", syncSidebar)?.append(sidebarToggle);' in console
|
||||
assert "function syncSidebarLogBaseline()" in console
|
||||
assert "const requiredOverviewHeight = overviewRect" in console
|
||||
assert "overviewBottom - overviewRect.top + overviewPaddingBottom + 10" in console
|
||||
assert 'sidebar.style.setProperty("--v2-sync-log-top"' in console
|
||||
assert 'window.addEventListener("resize", syncSidebarLogBaseline);' in console
|
||||
assert "requestAnimationFrame(syncSidebarLogBaseline);" in console
|
||||
|
||||
assert "--v2-sync-log-top: 291px;" in styles
|
||||
assert "grid-template-rows: calc(var(--v2-sync-log-top) - 10px) minmax(0, 1fr);" in styles
|
||||
assert "grid-auto-rows: max-content;" in styles
|
||||
assert "align-content: start;" in styles
|
||||
assert "min-height: max-content;" in styles
|
||||
assert "align-self: start;" in styles
|
||||
assert "overflow: visible;" in styles
|
||||
assert ".v2-sync-sidebar > .v2-sync-log" in styles
|
||||
assert "grid-template-columns: 64px minmax(0, 1fr);" in styles
|
||||
assert ".v2-sidebar-collapsed #v2SidebarToggle" in styles
|
||||
|
||||
@@ -199,7 +199,14 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
response.__enter__.return_value = response
|
||||
response.read.return_value = stream_body.encode("utf-8")
|
||||
|
||||
with patch.dict(os.environ, {"OPENAI_STREAM": "true"}, clear=False):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPENAI_STREAM": "true",
|
||||
"IR_OPENAI_MODEL": "gpt-5.6-sol",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
with patch(
|
||||
"cognitive_agent.application.self_extension.urlopen",
|
||||
|
||||
@@ -33,8 +33,12 @@ class _FakeBus:
|
||||
self.send_attempts: list[tuple[int, bytes, bool, bool]] = []
|
||||
self.sent: list[CanFrame] = []
|
||||
self.events: list[str] = []
|
||||
self.debug_commands: list[str] = []
|
||||
self.closed = False
|
||||
|
||||
def set_debug_command(self, command: str) -> None:
|
||||
self.debug_commands.append(command)
|
||||
|
||||
def send(
|
||||
self,
|
||||
arbitration_id: int,
|
||||
@@ -326,7 +330,7 @@ class VerifiedBodyCanTests(unittest.TestCase):
|
||||
self.assertEqual(vehicle[0]["capability"], "door.set_vehicle_lock")
|
||||
self.assertEqual(vehicle[0]["target"], "vehicle")
|
||||
|
||||
def test_legacy_rear_lock_is_retired_before_compile(self) -> None:
|
||||
def test_rear_lock_compiles_only_the_matrix_backed_second_row_request(self) -> None:
|
||||
action = {
|
||||
"domain": "door",
|
||||
"capability": "door.set_rear_lock",
|
||||
@@ -335,23 +339,55 @@ class VerifiedBodyCanTests(unittest.TestCase):
|
||||
"parameters": {"state": "lock"},
|
||||
}
|
||||
catalog_entry = self.runtime.capabilities[action["capability"]]
|
||||
self.assertEqual(catalog_entry["execution_support"], "observational_only")
|
||||
self.assertFalse(catalog_entry["physical_control_supported"])
|
||||
self.assertNotIn(action["capability"], self.runtime.supported_capabilities)
|
||||
self.assertFalse(self.runtime.supports(action))
|
||||
with self.assertRaisesRegex(
|
||||
NativeCanCompileError,
|
||||
r"unsupported.*current CH1.*0x32B.*observational only",
|
||||
):
|
||||
self.runtime.build_function_config(
|
||||
"door", [action], "retired-rear-lock", "local_rules"
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
NativeCanCompileError, r"unsupported.*current CH1.*observational only"
|
||||
):
|
||||
self.runtime._frame_specs({"capability": action["capability"]})
|
||||
self.assertEqual(
|
||||
catalog_entry["execution_support"], "matrix_candidate_with_feedback"
|
||||
)
|
||||
self.assertTrue(catalog_entry["physical_control_supported"])
|
||||
self.assertIn(action["capability"], self.runtime.supported_capabilities)
|
||||
self.assertTrue(self.runtime.supports(action))
|
||||
cases = (
|
||||
("lock", "0A 40 00 00 00 00 00 00"),
|
||||
("unlock", "0A 80 00 00 00 00 00 00"),
|
||||
)
|
||||
for state, payload in cases:
|
||||
with self.subTest(state=state):
|
||||
command = self.compile_action(
|
||||
"door", "door.set_rear_lock", "rear", {"state": state}
|
||||
)
|
||||
self.assertEqual(command["signals"]["frame_id"], "0x32B")
|
||||
self.assertEqual(command["signals"]["data_hex"], payload)
|
||||
self.assertFalse(command["signals"]["is_fd"])
|
||||
self.assertFalse(command["signals"]["bitrate_switch"])
|
||||
self.assertNotIn("repeat", command)
|
||||
self.assertEqual(command["signals"]["feedback_ids"], "0x407")
|
||||
self.assertEqual(
|
||||
command["signals"]["expected_feedback"], f"rear_lock:{state}"
|
||||
)
|
||||
exact = _parse_exact_command(f"door rear {state}")
|
||||
self.assertEqual(exact[0]["capability"], "door.set_rear_lock")
|
||||
self.assertEqual(exact[0]["target"], "rear")
|
||||
|
||||
self.assertEqual(_parse_exact_command("door rear lock"), [])
|
||||
def test_climate_cycle_uses_verified_1de_bits_and_404_feedback(self) -> None:
|
||||
cases = (
|
||||
("internal", "00 00 08 00 00 00 00 00"),
|
||||
("external", "00 00 10 00 00 00 00 00"),
|
||||
)
|
||||
for mode, payload in cases:
|
||||
with self.subTest(mode=mode):
|
||||
command = self.compile_action(
|
||||
"climate",
|
||||
"climate.set_cycle_mode",
|
||||
"vehicle",
|
||||
{"mode": mode},
|
||||
)
|
||||
self.assertEqual(command["signals"]["frame_id"], "0x1DE")
|
||||
self.assertEqual(command["signals"]["data_hex"], payload)
|
||||
self.assertEqual(command["repeat"], {"count": 3, "period_ms": 100})
|
||||
self.assertEqual(command["signals"]["feedback_ids"], "0x404")
|
||||
self.assertEqual(
|
||||
command["signals"]["expected_feedback"],
|
||||
f"climate_cycle:{mode}",
|
||||
)
|
||||
|
||||
def test_ambient_controls_use_only_verified_3fc_layouts(self) -> None:
|
||||
cases = (
|
||||
@@ -426,6 +462,10 @@ class VerifiedBodyCanTests(unittest.TestCase):
|
||||
self.assertFalse(command["signals"]["is_fd"])
|
||||
self.assertEqual(command["signals"]["feedback_ids"], "0x41A")
|
||||
self.assertEqual(command["signals"]["special_handler"], "shade_preset")
|
||||
self.assertEqual(
|
||||
command["signals"]["debug_command"],
|
||||
f"shade position {position}",
|
||||
)
|
||||
|
||||
arbitrary = self.compile_action(
|
||||
"roof", "roof.set_shade_position", "vehicle", {"position_pct": 37}
|
||||
@@ -433,10 +473,35 @@ class VerifiedBodyCanTests(unittest.TestCase):
|
||||
self.assertEqual(arbitrary["signals"]["frame_id"], "0x431")
|
||||
self.assertNotEqual(arbitrary["signals"]["frame_id"], "0x4F5")
|
||||
self.assertEqual(arbitrary["signals"]["special_handler"], "shade_position")
|
||||
self.assertEqual(arbitrary["signals"]["debug_command"], "shade position 37")
|
||||
special = arbitrary["signals"]["special_parameters"]
|
||||
self.assertIn('"fresh_feedback_timeout_ms": 2500', special)
|
||||
self.assertIn('"stop_data_hex": "40 00 00 00 00 00 00 00"', special)
|
||||
|
||||
def test_executor_attaches_compiled_debug_command_to_mqtt_style_bus(self) -> None:
|
||||
config = self.runtime.build_function_config(
|
||||
"roof",
|
||||
[
|
||||
{
|
||||
"domain": "roof",
|
||||
"capability": "roof.set_shade_position",
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"position_pct": 100},
|
||||
}
|
||||
],
|
||||
"shade-debug-command",
|
||||
"local_rules",
|
||||
)
|
||||
plan = self.runtime.compile(config)
|
||||
bus = _FakeBus([self.shade_feedback(100, motion=1)])
|
||||
executor = NativeCanExecutor(lambda: bus, feedback_timeout_ms=1)
|
||||
|
||||
report = executor.execute(self.runtime, plan, config)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual(bus.debug_commands, ["shade position 100"])
|
||||
|
||||
def test_shade_preset_executor_waits_for_authoritative_target(self) -> None:
|
||||
command = self.compile_action(
|
||||
"roof", "roof.set_shade_position", "vehicle", {"position_pct": 100}
|
||||
|
||||
Reference in New Issue
Block a user