bfff1b515c
Expose safe NFC-bound infrared actions to semantic scene composition, require flashing-light actions for romantic and Mid-Autumn modes, and preserve mixed CAN/IR mode flows. Fix NFC device reassignment and retry extension/toy MQTT publication when an active card's binding catalog changes.
280 lines
9.7 KiB
Python
280 lines
9.7 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from collections import Counter
|
|
from typing import Any
|
|
|
|
from cognitive_agent.application.extension_toy_publisher import (
|
|
NfcExtensionToyPublisher,
|
|
build_extension_toy_messages,
|
|
normalize_extension_nfc_id,
|
|
)
|
|
|
|
|
|
def wait_until(predicate, timeout: float = 1.0) -> bool:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return True
|
|
time.sleep(0.005)
|
|
return bool(predicate())
|
|
|
|
|
|
def capability(
|
|
capability_id: str,
|
|
*,
|
|
device_id: str = "toy-light-001",
|
|
device_name: str = "闪烁灯",
|
|
card_uid: str = "E113B203",
|
|
slot: int = 2,
|
|
action: str = "打开",
|
|
enabled: bool = True,
|
|
status: str = "learned",
|
|
**extra: Any,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"id": capability_id,
|
|
"status": status,
|
|
"enabled": enabled,
|
|
"device_id": device_id,
|
|
"device_name": device_name,
|
|
"card_uid": card_uid,
|
|
"slot": slot,
|
|
"action": action,
|
|
**extra,
|
|
}
|
|
|
|
|
|
def test_message_projection_groups_devices_orders_slots_and_filters_catalog() -> None:
|
|
catalog = [
|
|
capability("light-blue", slot=5, action="切换为蓝色"),
|
|
capability("fan-on", device_id="toy-fan-001", device_name="小风扇"),
|
|
capability("light-on", slot=2, action="打开"),
|
|
capability("duplicate", slot=2, action="打开"),
|
|
capability("disabled", slot=1, action="关闭", enabled=False),
|
|
capability("other-card", slot=6, action="设置为白色", card_uid="04A1B2C3"),
|
|
capability("draft", slot=3, action="切换为红色", status="draft"),
|
|
capability("missing-enabled", slot=4, action="切换为绿色", enabled=None),
|
|
]
|
|
|
|
messages = build_extension_toy_messages("e1:13-b2 03", catalog)
|
|
|
|
assert [message["extension_name"] for message in messages] == ["小风扇", "闪烁灯"]
|
|
fan, light = messages
|
|
assert fan["actions"] == [
|
|
{
|
|
"type": "infrared_emit",
|
|
"at_ms": 0,
|
|
"domain": "self_extension",
|
|
"capability": "infrared.emit",
|
|
"target_scope": "toy-fan-001",
|
|
"operation": "emit",
|
|
"parameters": {"action": "打开", "slot": 2},
|
|
}
|
|
]
|
|
assert [action["parameters"] for action in light["actions"]] == [
|
|
{"action": "打开", "slot": 2},
|
|
{"action": "切换为蓝色", "slot": 5},
|
|
]
|
|
assert {action["target_scope"] for action in light["actions"]} == {
|
|
"toy-light-001"
|
|
}
|
|
|
|
|
|
def test_projection_prefers_chinese_name_but_keeps_english_stable_scope() -> None:
|
|
catalog = [
|
|
capability(
|
|
"desk-light-on",
|
|
device_id="desk-light-01",
|
|
device_name="Desk Light",
|
|
object_names=["闪烁灯"],
|
|
)
|
|
]
|
|
|
|
[message] = build_extension_toy_messages("E113B203", catalog)
|
|
|
|
assert message["extension_name"] == "闪烁灯"
|
|
assert message["actions"][0]["target_scope"] == "desk-light-01"
|
|
assert normalize_extension_nfc_id("04:a1-b2 c3") == "04A1B2C3"
|
|
assert normalize_extension_nfc_id("not-a-card") == ""
|
|
|
|
|
|
def test_each_detection_publishes_three_frames_per_device_and_then_stops() -> None:
|
|
nfc: dict[str, Any] = {
|
|
"reader": {"online": True},
|
|
"card": {"action": "present", "uid": "E113B203"},
|
|
}
|
|
catalog = [
|
|
capability("light-on"),
|
|
capability("light-blue", slot=5, action="切换为蓝色"),
|
|
capability("fan-on", device_id="toy-fan-001", device_name="小风扇"),
|
|
]
|
|
publications: list[tuple[float, dict[str, Any]]] = []
|
|
|
|
def publish(payload: dict[str, Any]) -> dict[str, Any]:
|
|
publications.append((time.monotonic(), payload))
|
|
return {"delivery_status": "queued", "message_id": len(publications)}
|
|
|
|
service = NfcExtensionToyPublisher(
|
|
nfc_provider=lambda: nfc,
|
|
catalog_provider=lambda: catalog,
|
|
publisher=publish,
|
|
burst_window_s=0.12,
|
|
poll_interval_s=0.005,
|
|
)
|
|
service.start()
|
|
try:
|
|
assert wait_until(lambda: len(publications) == 6)
|
|
assert publications[-1][0] - publications[0][0] <= 0.12
|
|
assert Counter(
|
|
publication["actions"][0]["target_scope"]
|
|
for _at, publication in publications
|
|
) == {"toy-fan-001": 3, "toy-light-001": 3}
|
|
status = service.snapshot()
|
|
assert status["state"] == "burst_complete"
|
|
assert status["burst_frame_attempt_count"] == 3
|
|
assert status["burst_publish_attempt_count"] == 6
|
|
assert status["burst_published_count"] == 6
|
|
assert status["qos"] == 1
|
|
assert status["retain"] is False
|
|
|
|
time.sleep(0.14)
|
|
assert len(publications) == 6
|
|
|
|
nfc["card"] = None
|
|
assert wait_until(lambda: service.snapshot()["state"] == "waiting_for_nfc")
|
|
nfc["card"] = {"action": "present", "uid": "E113B203"}
|
|
assert wait_until(lambda: len(publications) == 12)
|
|
assert service.snapshot()["state"] == "burst_complete"
|
|
finally:
|
|
service.close()
|
|
|
|
|
|
def test_no_matching_ability_publishes_nothing() -> None:
|
|
nfc = {"card": {"action": "present", "uid": "E113B203"}}
|
|
publications: list[dict[str, Any]] = []
|
|
service = NfcExtensionToyPublisher(
|
|
nfc_provider=lambda: nfc,
|
|
catalog_provider=lambda: [
|
|
capability("other-card", card_uid="04A1B2C3"),
|
|
capability("disabled", enabled=False),
|
|
],
|
|
publisher=lambda payload: publications.append(dict(payload)) or {},
|
|
burst_window_s=0.03,
|
|
poll_interval_s=0.005,
|
|
)
|
|
service.start()
|
|
try:
|
|
assert wait_until(
|
|
lambda: service.snapshot()["state"] == "no_matching_extension"
|
|
)
|
|
time.sleep(0.05)
|
|
assert publications == []
|
|
finally:
|
|
service.close()
|
|
|
|
|
|
def test_binding_a_still_present_card_retries_after_catalog_revision_change() -> None:
|
|
nfc = {"card": {"action": "present", "uid": "E113B203"}}
|
|
catalog: list[dict[str, Any]] = []
|
|
catalog_revision = {"value": "catalog-a"}
|
|
publications: list[dict[str, Any]] = []
|
|
service = NfcExtensionToyPublisher(
|
|
nfc_provider=lambda: nfc,
|
|
catalog_provider=lambda: catalog,
|
|
catalog_revision_provider=lambda: catalog_revision["value"],
|
|
publisher=lambda payload: publications.append(dict(payload))
|
|
or {"delivery_status": "queued", "message_id": len(publications)},
|
|
burst_window_s=0.06,
|
|
poll_interval_s=0.005,
|
|
)
|
|
service.start()
|
|
try:
|
|
assert wait_until(
|
|
lambda: service.snapshot()["state"] == "no_matching_extension"
|
|
)
|
|
assert publications == []
|
|
|
|
catalog.append(capability("new-device-on"))
|
|
catalog_revision["value"] = "catalog-b"
|
|
|
|
assert wait_until(lambda: len(publications) == 3)
|
|
status = service.snapshot()
|
|
assert status["state"] == "burst_complete"
|
|
assert status["topic"] == "extension/toy"
|
|
assert status["active_catalog_revision"] == "catalog-b"
|
|
assert status["burst_published_count"] == 3
|
|
|
|
catalog[:] = [
|
|
capability(
|
|
"replacement-device-on",
|
|
device_id="toy-light-003",
|
|
device_name="新灯带",
|
|
slot=21,
|
|
)
|
|
]
|
|
catalog_revision["value"] = "catalog-c"
|
|
assert wait_until(lambda: len(publications) == 6)
|
|
assert publications[-1]["actions"][0]["target_scope"] == "toy-light-003"
|
|
|
|
# An unrelated revision with identical abilities must not duplicate a burst.
|
|
catalog_revision["value"] = "catalog-d"
|
|
time.sleep(0.08)
|
|
assert len(publications) == 6
|
|
finally:
|
|
service.close()
|
|
|
|
|
|
def test_failed_burst_attempts_only_three_frames_until_redetected() -> None:
|
|
nfc: dict[str, Any] = {"card": {"action": "present", "uid": "E113B203"}}
|
|
attempts: list[float] = []
|
|
|
|
def fail_publish(_payload: dict[str, Any]) -> dict[str, Any]:
|
|
attempts.append(time.monotonic())
|
|
raise RuntimeError("broker unavailable")
|
|
|
|
service = NfcExtensionToyPublisher(
|
|
nfc_provider=lambda: nfc,
|
|
catalog_provider=lambda: [capability("light-on")],
|
|
publisher=fail_publish,
|
|
burst_window_s=0.06,
|
|
poll_interval_s=0.005,
|
|
)
|
|
service.start()
|
|
try:
|
|
assert wait_until(lambda: len(attempts) == 3)
|
|
status = service.snapshot()
|
|
assert status["state"] == "burst_failed"
|
|
assert status["burst_publish_attempt_count"] == 3
|
|
assert status["burst_published_count"] == 0
|
|
assert status["last_error"] == "broker unavailable"
|
|
time.sleep(0.08)
|
|
assert len(attempts) == 3
|
|
finally:
|
|
service.close()
|
|
|
|
|
|
def test_server_wires_publisher_without_invoking_infrared_execution() -> None:
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parents[2]
|
|
server = (root / "src" / "cognitive_agent" / "server.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
service = (
|
|
root / "src" / "cognitive_agent" / "application" / "seat_service.py"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "extension_toys = NfcExtensionToyPublisher(" in server
|
|
assert "catalog_provider=extension.list_peripheral_capabilities" in server
|
|
assert "catalog_revision_provider=extension.peripheral_catalog_revision" in server
|
|
assert "publisher=service.publish_extension_toy_message" in server
|
|
assert "SeatDemoHandler.extension_toys.start()" in server
|
|
assert "SeatDemoHandler.extension_toys.close()" in server
|
|
assert "publish_extension_toy_message" in service
|
|
method = service.split("def publish_extension_toy_message", 1)[1].split(
|
|
"\n def ", 1
|
|
)[0]
|
|
assert "_vehicle_mqtt_transport.publish_extension_toy_message" in method
|
|
assert "execute_gateway" not in method
|