优化前端,优化前端指令,优化自定义控制
This commit is contained in:
@@ -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