c812a1a595
Integrate the remote firmware workbench, OTA delivery, and timed mode schedules while preserving the local N7 scene and runtime changes.
491 lines
18 KiB
Python
491 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
|
from cognitive_agent.application.mode_scene_catalog import (
|
|
ModeSceneCatalogPublisher,
|
|
ModeSceneMqttBurstPublisher,
|
|
build_mode_scene_catalog,
|
|
)
|
|
from cognitive_agent.application.mode_schedule import ModeSchedule
|
|
from cognitive_agent.application.seat_service import REPOSITORY_ROOT
|
|
|
|
|
|
def _write_office_preset(path: Path) -> None:
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(
|
|
"""
|
|
id: office
|
|
name: 办公模式
|
|
description: N7 办公场景。
|
|
icon: briefcase
|
|
scope:
|
|
vehicle_ids: [denza_n7]
|
|
operations:
|
|
- domain: climate
|
|
capability: climate.set_temperature
|
|
target_scope: passenger
|
|
operation: set
|
|
parameters:
|
|
temperature_c: 24
|
|
""".strip()
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def test_publisher_tracks_create_override_and_delete(tmp_path: Path) -> None:
|
|
preset_root = tmp_path / "presets"
|
|
user_root = tmp_path / "user"
|
|
_write_office_preset(
|
|
preset_root / "vehicles" / "denza_n7" / "preset" / "office.yaml"
|
|
)
|
|
registry = ModeProfileRegistry(preset_root, user_root=user_root)
|
|
catalog_path = tmp_path / "scenses" / "demo-scenes.json"
|
|
publisher = ModeSceneCatalogPublisher(
|
|
catalog_path,
|
|
"denza_n7",
|
|
server_id="test-n7",
|
|
)
|
|
registry.add_change_listener(publisher.publish)
|
|
|
|
initial = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
assert initial["service"] == "n7-scenes"
|
|
assert initial["schema_version"] == 1
|
|
assert initial["server_id"] == "test-n7"
|
|
assert initial["scene_count"] == 1
|
|
assert initial["scenes"][0]["origin"] == "preset"
|
|
assert initial["scenes"][0]["actions"] == [
|
|
{
|
|
"type": "climate_temperature",
|
|
"at_ms": 0,
|
|
"domain": "climate",
|
|
"capability": "climate.set_temperature",
|
|
"target_scope": "passenger",
|
|
"operation": "set",
|
|
"parameters": {"temperature_c": 24},
|
|
"zone": "passenger",
|
|
"celsius": 24,
|
|
}
|
|
]
|
|
|
|
created = registry.save_mode(
|
|
"阅读模式",
|
|
[
|
|
{
|
|
"domain": "ambient_light",
|
|
"capability": "ambient_light.set_illumination",
|
|
"target_scope": "vehicle",
|
|
"parameters": {"color_hex": "#E8F3FF", "brightness_pct": 40},
|
|
}
|
|
],
|
|
vehicle_id="denza_n7",
|
|
)
|
|
after_create = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
created_scene = next(
|
|
scene for scene in after_create["scenes"] if scene["id"] == created.id
|
|
)
|
|
assert after_create["scene_count"] == 2
|
|
assert created_scene["origin"] == "user"
|
|
assert created_scene["deletable"] is True
|
|
assert created_scene["actions"][0]["parameters"]["brightness_pct"] == 40
|
|
|
|
fan_mode = registry.save_mode(
|
|
"微风模式",
|
|
[
|
|
{
|
|
"domain": "climate",
|
|
"capability": "climate.set_fan_level",
|
|
"target_scope": "vehicle",
|
|
"parameters": {"level": 2},
|
|
}
|
|
],
|
|
vehicle_id="denza_n7",
|
|
)
|
|
after_fan_create = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
fan_scene = next(
|
|
scene for scene in after_fan_create["scenes"] if scene["id"] == fan_mode.id
|
|
)
|
|
assert fan_scene["actions"][0]["type"] == "climate_fan"
|
|
assert fan_scene["actions"][0]["level"] == 2
|
|
|
|
registry.save_mode(
|
|
"专注办公模式",
|
|
[
|
|
{
|
|
"domain": "climate",
|
|
"capability": "climate.set_temperature",
|
|
"target_scope": "passenger",
|
|
"parameters": {"temperature_c": 26},
|
|
}
|
|
],
|
|
mode_id="office",
|
|
vehicle_id="denza_n7",
|
|
)
|
|
after_override = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
office = next(scene for scene in after_override["scenes"] if scene["id"] == "office")
|
|
assert office["label"] == "专注办公模式"
|
|
assert office["origin"] == "customized"
|
|
assert office["preset_override"] is True
|
|
assert office["actions"][0]["celsius"] == 26
|
|
|
|
registry.delete_user_mode(created.id, "denza_n7")
|
|
registry.delete_user_mode(fan_mode.id, "denza_n7")
|
|
registry.delete_user_mode("office", "denza_n7")
|
|
after_delete = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
assert [scene["id"] for scene in after_delete["scenes"]] == ["office"]
|
|
assert after_delete["scenes"][0]["label"] == "办公模式"
|
|
assert after_delete["scenes"][0]["origin"] == "preset"
|
|
|
|
|
|
def test_repository_presets_are_exported_without_internal_command_flows() -> None:
|
|
registry = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes")
|
|
|
|
catalog = build_mode_scene_catalog(
|
|
registry,
|
|
"denza_n7",
|
|
generated_at="2026-09-09T00:00:00Z",
|
|
)
|
|
|
|
assert catalog["scene_count"] >= 13
|
|
assert len({scene["id"] for scene in catalog["scenes"]}) == catalog["scene_count"]
|
|
by_id = {scene["id"]: scene for scene in catalog["scenes"]}
|
|
assert by_id["office"]["operation_count"] == len(registry.get_profile("office", "denza_n7").operations)
|
|
assert by_id["rocking_horse"]["behavior"] == "cyclic"
|
|
assert by_id["rocking_horse"]["timeline"]["repeat"] == "until_stopped_or_timeout"
|
|
encoded = json.dumps(catalog, ensure_ascii=False)
|
|
assert "command_flow" not in encoded
|
|
assert "source_request_id" not in encoded
|
|
|
|
|
|
def test_saved_editor_flow_projects_all_native_and_extension_operations(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
registry = ModeProfileRegistry(
|
|
tmp_path / "presets",
|
|
user_root=tmp_path / "profiles",
|
|
)
|
|
operations = [
|
|
{
|
|
"domain": "climate",
|
|
"capability": "climate.set_temperature",
|
|
"target_scope": "driver",
|
|
"parameters": {"temperature_c": 24},
|
|
},
|
|
{
|
|
"domain": "interior_light",
|
|
"capability": "interior_light.set_reading_power",
|
|
"target_scope": "all",
|
|
"parameters": {"state": "on"},
|
|
},
|
|
{
|
|
"domain": "roof",
|
|
"capability": "roof.set_shade_motion",
|
|
"target_scope": "vehicle",
|
|
"parameters": {"motion": "open"},
|
|
},
|
|
{
|
|
"domain": "window",
|
|
"capability": "window.set_position",
|
|
"target_scope": "front_right",
|
|
"parameters": {"position_pct": 50},
|
|
},
|
|
]
|
|
|
|
def configured_operation(
|
|
operation_id: str,
|
|
capability: str,
|
|
zone: str,
|
|
parameters: dict[str, object],
|
|
*,
|
|
device: str,
|
|
) -> dict[str, object]:
|
|
return {
|
|
"id": operation_id,
|
|
"capability": capability,
|
|
"parameters": parameters,
|
|
"target": {"device": device, "zone": zone},
|
|
}
|
|
|
|
function_configs = {
|
|
"native_climate": {
|
|
"spec": {
|
|
"domain": "climate",
|
|
"operations": [
|
|
configured_operation(
|
|
"driver_set_temperature_1",
|
|
"climate.set_temperature",
|
|
"driver",
|
|
{"temperature_c": 24},
|
|
device="hvac",
|
|
)
|
|
],
|
|
}
|
|
},
|
|
"native_interior_light": {
|
|
"spec": {
|
|
"domain": "interior_light",
|
|
"operations": [
|
|
configured_operation(
|
|
"all_set_reading_power_1",
|
|
"interior_light.set_reading_power",
|
|
"all",
|
|
{"state": "on"},
|
|
device="reading_light",
|
|
)
|
|
],
|
|
}
|
|
},
|
|
"native_roof": {
|
|
"spec": {
|
|
"domain": "roof",
|
|
"operations": [
|
|
configured_operation(
|
|
"vehicle_set_shade_motion_1",
|
|
"roof.set_shade_motion",
|
|
"vehicle",
|
|
{"motion": "open"},
|
|
device="roof_shade",
|
|
)
|
|
],
|
|
}
|
|
},
|
|
"native_window": {
|
|
"spec": {
|
|
"domain": "window",
|
|
"operations": [
|
|
configured_operation(
|
|
"front_right_set_position_1",
|
|
"window.set_position",
|
|
"front_right",
|
|
{"position_pct": 50},
|
|
device="window",
|
|
)
|
|
],
|
|
}
|
|
},
|
|
"self_extension": {
|
|
"spec": {
|
|
"domain": "self_extension",
|
|
"operations": [
|
|
{
|
|
"id": "ir-slot:toy-light-001:2-1",
|
|
"capability": "infrared.emit",
|
|
"parameters": {"action": "打开", "slot": 2},
|
|
"target": {
|
|
"card_uid": "2CC8B103",
|
|
"device": "toy-light-001",
|
|
"name": "闪烁灯",
|
|
},
|
|
},
|
|
{
|
|
"id": "ir-slot:toy-light-001:6-2",
|
|
"capability": "infrared.emit",
|
|
"parameters": {"action": "设置为白色", "slot": 6},
|
|
"target": {
|
|
"card_uid": "2CC8B103",
|
|
"device": "toy-light-001",
|
|
"name": "闪烁灯",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
},
|
|
}
|
|
registry.save_mode(
|
|
"阅读模式",
|
|
operations,
|
|
mode_id="mode-reading",
|
|
vehicle_id="denza_n7",
|
|
command_flow={"function_configs": function_configs},
|
|
)
|
|
|
|
catalog = build_mode_scene_catalog(
|
|
registry,
|
|
"denza_n7",
|
|
generated_at="2026-09-15T00:00:00Z",
|
|
)
|
|
reading = next(
|
|
scene for scene in catalog["scenes"] if scene["id"] == "mode-reading"
|
|
)
|
|
|
|
assert reading["operation_count"] == 6
|
|
assert len(reading["actions"]) == 6
|
|
assert [action["capability"] for action in reading["actions"]] == [
|
|
"climate.set_temperature",
|
|
"interior_light.set_reading_power",
|
|
"roof.set_shade_motion",
|
|
"window.set_position",
|
|
"infrared.emit",
|
|
"infrared.emit",
|
|
]
|
|
assert reading["domains"] == [
|
|
"climate",
|
|
"interior_light",
|
|
"roof",
|
|
"self_extension",
|
|
"window",
|
|
]
|
|
first_infrared, second_infrared = reading["actions"][-2:]
|
|
assert first_infrared["parameters"] == {"action": "打开", "slot": 2}
|
|
assert second_infrared["parameters"] == {"action": "设置为白色", "slot": 6}
|
|
assert first_infrared["target"] == {
|
|
"card_uid": "2CC8B103",
|
|
"device": "toy-light-001",
|
|
"name": "闪烁灯",
|
|
}
|
|
assert second_infrared["target"] == first_infrared["target"]
|
|
assert first_infrared["operation"] == "emit"
|
|
|
|
|
|
def test_mode_scene_mqtt_burst_publishes_only_one_exact_card_three_times() -> None:
|
|
publications: list[tuple[float, dict[str, object]]] = []
|
|
complete = threading.Event()
|
|
scene: dict[str, object] = {
|
|
"id": "mode-reading",
|
|
"label": "阅读模式",
|
|
"name": "阅读模式",
|
|
"background_time": "noon",
|
|
"description": "由自然语言生成的自定义模式。",
|
|
"icon": "bookmark-plus",
|
|
"aliases": ["阅读模式", "阅读"],
|
|
"origin": "user",
|
|
"deletable": True,
|
|
"behavior": "static",
|
|
"domains": ["climate"],
|
|
"operation_count": 1,
|
|
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
|
|
"profile_revision": "1234567890abcdef",
|
|
"actions": [
|
|
{
|
|
"type": "climate_temperature",
|
|
"at_ms": 0,
|
|
"domain": "climate",
|
|
"capability": "climate.set_temperature",
|
|
"target_scope": "passenger",
|
|
"operation": "set",
|
|
"parameters": {"temperature_c": 24},
|
|
"zone": "passenger",
|
|
"celsius": 24,
|
|
}
|
|
],
|
|
}
|
|
|
|
def publish(payload: dict[str, object]) -> dict[str, object]:
|
|
publications.append((time.monotonic(), payload))
|
|
if len(publications) == 3:
|
|
complete.set()
|
|
return {"delivery_status": "published", "message_id": len(publications)}
|
|
|
|
publisher = ModeSceneMqttBurstPublisher(
|
|
publish,
|
|
topic="from_pc/scenes",
|
|
burst_window_s=0.12,
|
|
)
|
|
queued_at = time.monotonic()
|
|
try:
|
|
queued = publisher.queue(scene)
|
|
assert complete.wait(timeout=1.0)
|
|
assert queued == {
|
|
"state": "queued",
|
|
"topic": "from_pc/scenes",
|
|
"scene_id": "mode-reading",
|
|
"burst_count": 3,
|
|
"burst_window_ms": 120,
|
|
}
|
|
assert len(publications) == 3
|
|
assert publications[-1][0] - queued_at < 0.2
|
|
assert all(payload == scene for _, payload in publications)
|
|
assert all("scenes" not in payload for _, payload in publications)
|
|
assert publisher.snapshot()["state"] == "burst_complete"
|
|
finally:
|
|
publisher.close()
|
|
|
|
|
|
def _schedule() -> dict:
|
|
return ModeSchedule.from_dict({
|
|
"schema": "cognitive-agent-mode-schedule/v1",
|
|
"repeat_count": 2,
|
|
"phases": [
|
|
{"id": "warm", "name": "暖舱", "duration_ms": 1000, "operations": [
|
|
{"domain": "climate", "capability": "climate.set_temperature", "target_scope": "vehicle",
|
|
"operation": "set", "parameters": {"temperature_c": 28}},
|
|
]},
|
|
{"id": "settle", "name": "舒适", "duration_ms": 2000, "operations": [
|
|
{"domain": "climate", "capability": "climate.set_temperature", "target_scope": "vehicle",
|
|
"operation": "set", "parameters": {"temperature_c": 24}},
|
|
{"domain": "seat", "capability": "seat.set_backrest_recline", "target_scope": "passenger",
|
|
"operation": "set", "parameters": {"position_pct": 45}},
|
|
]},
|
|
],
|
|
}).to_dict()
|
|
|
|
|
|
def test_schedule_catalog_preserves_phases_but_never_exports_flat_executable_actions(tmp_path: Path) -> None:
|
|
registry = ModeProfileRegistry(tmp_path / "presets", user_root=tmp_path / "user")
|
|
schedule = _schedule()
|
|
profile = registry.save_mode(
|
|
"分阶段舒适模式", schedule["phases"][0]["operations"], vehicle_id="denza_n7",
|
|
schedule=ModeSchedule.from_dict(schedule),
|
|
command_flow={"source_request_id": "req-private", "raw_can": "private-transport-bytes"},
|
|
)
|
|
catalog = build_mode_scene_catalog(registry, "denza_n7")
|
|
scene = next(item for item in catalog["scenes"] if item["id"] == profile.id)
|
|
assert scene["schedule"] == schedule
|
|
assert scene["behavior"] == "cyclic"
|
|
assert scene["operation_count"] == 3
|
|
assert scene["domains"] == ["climate", "seat"]
|
|
assert scene["actions"] == [], "legacy clients must not execute all timeline stages at once"
|
|
assert scene["execution"] == {
|
|
"kind": "server_schedule", "mode_id": profile.id,
|
|
"client_actions_supported": False, "requires_authentication": True,
|
|
}
|
|
assert "服务端时间轴" in scene["execution_notice"]
|
|
assert scene["preview_actions_scope"] == "single_cycle"
|
|
preview = scene["preview_actions"]
|
|
assert [action["phase_id"] for action in preview] == ["warm", "settle", "settle"]
|
|
assert [action["at_ms"] for action in preview] == [0, 1000, 1000]
|
|
assert [action["phase_duration_ms"] for action in preview] == [1000, 2000, 2000]
|
|
assert [action["celsius"] for action in preview if action["type"] == "climate_temperature"] == [28, 24]
|
|
assert preview[-1]["target_scope"] == "passenger"
|
|
assert preview[-1]["parameters"] == {"position_pct": 45}
|
|
assert ModeSchedule.from_dict(scene["schedule"]).total_duration_ms == 6000
|
|
encoded = json.dumps(catalog)
|
|
assert "source_request_id" not in encoded
|
|
assert "private-transport-bytes" not in encoded
|
|
assert "command_flow" not in encoded
|
|
|
|
|
|
def test_schedule_publisher_updates_after_save_reload_duration_change_and_delete(tmp_path: Path) -> None:
|
|
registry = ModeProfileRegistry(tmp_path / "presets", user_root=tmp_path / "user")
|
|
publisher = ModeSceneCatalogPublisher(tmp_path / "catalog.json", "denza_n7")
|
|
registry.add_change_listener(publisher.publish)
|
|
assert publisher.snapshot()["scene_count"] == 0
|
|
schedule = _schedule()
|
|
created = registry.save_mode(
|
|
"动态释压模式", schedule["phases"][0]["operations"], vehicle_id="denza_n7",
|
|
schedule=ModeSchedule.from_dict(schedule),
|
|
)
|
|
first = publisher.snapshot()
|
|
assert first["scenes"][0]["schedule"] == schedule
|
|
registry.reload()
|
|
assert publisher.snapshot() == first
|
|
schedule["phases"][1]["duration_ms"] = 5000
|
|
registry.save_mode(
|
|
created.name, schedule["phases"][0]["operations"], vehicle_id="denza_n7",
|
|
mode_id=created.id, schedule=ModeSchedule.from_dict(schedule),
|
|
)
|
|
changed = publisher.snapshot()
|
|
assert changed["catalog_revision"] != first["catalog_revision"]
|
|
assert changed["scenes"][0]["profile_revision"] != first["scenes"][0]["profile_revision"]
|
|
assert changed["scenes"][0]["schedule"]["phases"][1]["duration_ms"] == 5000
|
|
assert changed["scenes"][0]["actions"] == []
|
|
assert json.loads(publisher.path.read_text(encoding="utf-8")) == changed
|
|
registry.delete_user_mode(created.id, "denza_n7")
|
|
assert publisher.snapshot()["scene_count"] == 0
|
|
assert publisher.snapshot()["scenes"] == []
|