feat(n7web): enhance control workflows and status feedback

This commit is contained in:
Miayui
2026-09-16 12:52:07 +08:00
parent 84a3cd73bb
commit 0e4328f548
45 changed files with 7317 additions and 1001 deletions
+187 -2
View File
@@ -17,8 +17,13 @@ from cognitive_agent.infrastructure.can.hardware import (
)
from cognitive_agent.infrastructure.can.mqtt_transport import (
MQTT_CAN_SCHEMA,
MQTT_EXTENSION_CONTROL_TOPIC,
MQTT_EXTENSION_TOY_TOPIC,
MQTT_MODE_SCENE_TOPIC,
MQTT_PET_TOPIC,
MqttCanTransport,
mqtt_extension_toy_payload,
mqtt_mode_scene_payload,
mqtt_pet_payload,
parse_mqtt_can_payload,
)
@@ -80,10 +85,19 @@ class _FakeMqttClient:
self.published.append((topic, payload, qos, retain))
return _PublishResult()
def emit(self, topic: str, payload: object) -> None:
def emit(
self,
topic: str,
payload: object,
*,
retain: bool = False,
dup: bool = False,
) -> None:
message = SimpleNamespace(
topic=topic,
payload=json.dumps(payload).encode("utf-8"),
retain=retain,
dup=dup,
)
self.on_message(self, None, message)
@@ -557,6 +571,174 @@ class MqttCanTransportTests(unittest.TestCase):
{"pet_name": "团团", "nfc_id": "not-a-uid", "action": "摇尾巴"}
)
def test_extension_toy_uses_fixed_topic_qos_one_and_never_retains(self) -> None:
# This application-level topic is contractually QoS 1 even if the
# vehicle CAN relay itself is configured with a different QoS.
self.transport.settings.qos = 0
payload = {
"extension_name": "闪烁灯",
"actions": [
{
"type": "infrared_emit",
"at_ms": 0,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": "toy-light-001",
"operation": "emit",
"parameters": {"action": "打开", "slot": 2},
}
],
}
publication = self.transport.publish_extension_toy_message(payload)
topic, raw_payload, qos, retain = self.client.published[-1]
self.assertEqual(topic, MQTT_EXTENSION_TOY_TOPIC)
self.assertEqual(raw_payload, mqtt_extension_toy_payload(payload))
self.assertEqual(json.loads(raw_payload), payload)
self.assertEqual(qos, 1)
self.assertFalse(retain)
self.assertEqual(publication["qos"], 1)
self.assertEqual(publication["delivery_status"], "published")
def test_extension_toy_rejects_non_english_scope_and_extra_fields(self) -> None:
action = {
"type": "infrared_emit",
"at_ms": 0,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": "闪烁灯",
"operation": "emit",
"parameters": {"action": "打开", "slot": 2},
}
with self.assertRaisesRegex(Exception, "English stable device id"):
self.transport.publish_extension_toy_message(
{"extension_name": "闪烁灯", "actions": [action]}
)
with self.assertRaisesRegex(Exception, "requires exactly"):
self.transport.publish_extension_toy_message(
{
"extension_name": "闪烁灯",
"actions": [{**action, "target_scope": "toy-light-001"}],
"execute": True,
}
)
def test_extension_control_is_dispatched_as_raw_bytes_without_blocking(self) -> None:
received: list[bytes] = []
self.transport.set_extension_control_handler(received.append)
payload = {
"domain": "self_extension",
"operation_count": 1,
"actions": [
{
"type": "infrared_emit",
"at_ms": 0,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": "toy-light-001",
"operation": "emit",
"parameters": {"action": "打开", "slot": 2},
}
],
}
self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload)
self.assertEqual(len(received), 1)
self.assertEqual(json.loads(received[0]), payload)
status = self.transport.public_info()["extension_control"]
self.assertTrue(status["handler_configured"])
self.assertEqual(status["received_count"], 1)
self.assertEqual(status["dispatched_count"], 1)
self.assertEqual(status["rejected_count"], 0)
def test_extension_control_rejects_retained_and_duplicate_deliveries(self) -> None:
received: list[bytes] = []
rejections: list[tuple[str, str]] = []
self.transport.set_extension_control_handler(received.append)
self.transport.set_extension_control_rejection_handler(
lambda error, kind: rejections.append((error, kind))
)
payload = {
"domain": "self_extension",
"operation_count": 1,
"actions": [],
}
self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload, retain=True)
self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload, dup=True)
self.assertEqual(received, [])
status = self.transport.public_info()["extension_control"]
self.assertEqual(status["received_count"], 2)
self.assertEqual(status["dispatched_count"], 0)
self.assertEqual(status["rejected_count"], 2)
self.assertEqual(status["retained_count"], 1)
self.assertEqual(status["duplicate_count"], 1)
self.assertTrue(status["rejection_handler_configured"])
self.assertEqual(
rejections,
[
("retained extension/ctl messages are not executable", "retained"),
("duplicate extension/ctl delivery was ignored", "duplicate"),
],
)
def test_mode_scene_uses_fixed_topic_exact_catalog_card_and_never_retains(
self,
) -> None:
scene = {
"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,
}
],
}
publication = self.transport.publish_mode_scene(scene)
topic, raw_payload, qos, retain = self.client.published[-1]
self.assertEqual(topic, MQTT_MODE_SCENE_TOPIC)
self.assertEqual(raw_payload, mqtt_mode_scene_payload(scene))
self.assertEqual(json.loads(raw_payload), scene)
self.assertNotIn("scenes", json.loads(raw_payload))
self.assertEqual(qos, 1)
self.assertFalse(retain)
self.assertEqual(publication["delivery_status"], "published")
def test_mode_scene_rejects_catalog_wrapper(self) -> None:
with self.assertRaisesRegex(Exception, "one scene only"):
self.transport.publish_mode_scene(
{
"service": "n7-scenes",
"scene_count": 1,
"scenes": [{"id": "mode-reading"}],
}
)
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
self.client.emit(
"n7/n7-headunit/can/state",
@@ -590,7 +772,10 @@ class MqttCanTransportTests(unittest.TestCase):
)
self.assertEqual(
self.client.subscriptions,
[("n7/n7-headunit/can/state", 1)],
[
("n7/n7-headunit/can/state", 1),
(MQTT_EXTENSION_CONTROL_TOPIC, 1),
],
)
def test_first_partial_live_vehicle_snapshot_does_not_wait_for_missing_ids(self) -> None: