feat(n7web): enhance control workflows and status feedback
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user