添加红外扩展

This commit is contained in:
minghanchen
2026-08-28 17:27:43 +08:00
parent fd13d9f1ee
commit 6d75944c0f
11 changed files with 1602 additions and 75 deletions
+95 -5
View File
@@ -349,11 +349,11 @@ operations:
) as response:
console_styles = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.js?v=nfc-toy-33", timeout=3
f"{self.base_url}/self-extension.js?v=gscu-tcp-37", timeout=3
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.css?v=nfc-toy-33", timeout=3
f"{self.base_url}/self-extension.css?v=gscu-tcp-37", timeout=3
) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open(
@@ -393,8 +393,8 @@ operations:
self.assertIn('<body class="console-v2">', html)
self.assertIn('id="v2BootScreen"', html)
self.assertIn('正在加载车机控制与实时状态', html)
self.assertIn('src="/self-extension.js?v=nfc-toy-33"', html)
self.assertIn('href="/self-extension.css?v=nfc-toy-33"', html)
self.assertIn('src="/self-extension.js?v=gscu-tcp-37"', html)
self.assertIn('href="/self-extension.css?v=gscu-tcp-37"', html)
self.assertNotIn("self-calibration", html)
self.assertNotIn("self-repair", html)
self.assertNotIn("self-evolution", html)
@@ -639,7 +639,7 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v210"', service_worker)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v214"', service_worker)
self.assertIn('"/app.js?v=dual-seat-107"', service_worker)
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
self.assertIn(".then(() => self.skipWaiting())", service_worker)
@@ -1037,6 +1037,96 @@ operations:
self.assertEqual(result["transport"], "simulation")
self.assertFalse(result["physical_state_confirmed"])
def test_self_extension_tcp_configuration_endpoint_updates_runtime(self) -> None:
original_extension = SeatDemoHandler.extension
extension = SelfExtensionService(SimulationGateway())
SeatDemoHandler.extension = extension
try:
with tempfile.TemporaryDirectory() as directory:
with patch.dict(
os.environ,
{"GSCU_RUNTIME_CONFIG_PATH": f"{directory}/gateway.json"},
clear=False,
):
request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/config",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{"listen_host": "127.0.0.1", "port": 45444}
).encode("utf-8"),
)
with self.opener.open(request, timeout=3) as response:
result = json.loads(response.read().decode("utf-8"))
persisted = json.loads(
Path(directory, "gateway.json").read_text(encoding="utf-8")
)
finally:
extension.close()
SeatDemoHandler.extension = original_extension
self.assertTrue(result["success"])
self.assertEqual(result["transport"], "tcp")
self.assertEqual(result["listen_host"], "127.0.0.1")
self.assertEqual(result["port"], 45444)
self.assertEqual(
persisted,
{"transport": "tcp", "listen_host": "127.0.0.1", "port": 45444},
)
def test_infrared_slot_catalog_api_and_confirmed_erase_all(self) -> None:
original_extension = SeatDemoHandler.extension
with tempfile.TemporaryDirectory() as directory:
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
)
SeatDemoHandler.extension = extension
try:
for slot, name in ((12, "电视开关"), (3, "空调制冷")):
request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/slots/{slot}",
method="PUT",
headers={"Content-Type": "application/json"},
data=json.dumps({"name": name, "source": "tcp"}).encode("utf-8"),
)
with self.opener.open(request, timeout=3) as response:
self.assertEqual(response.status, 200)
with self.opener.open(
f"{self.base_url}/api/extension/gscu/slots", timeout=3
) as response:
catalog = json.loads(response.read().decode("utf-8"))
self.assertEqual([item["slot"] for item in catalog["slots"]], [3, 12])
delete_request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/slots/3",
method="DELETE",
)
with self.opener.open(delete_request, timeout=3) as response:
deleted = json.loads(response.read().decode("utf-8"))
self.assertTrue(deleted["removed"])
self.assertFalse(deleted["device_signal_erased"])
erase_request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/erase-all",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps({"confirm": "ERASE_ALL"}).encode("utf-8"),
)
with self.opener.open(erase_request, timeout=3) as response:
erased = json.loads(response.read().decode("utf-8"))
self.assertTrue(erased["catalog_cleared"])
self.assertEqual(erased["catalog_removed_count"], 1)
with self.opener.open(
f"{self.base_url}/api/extension/gscu/slots", timeout=3
) as response:
empty_catalog = json.loads(response.read().decode("utf-8"))
self.assertEqual(empty_catalog["slots"], [])
finally:
SeatDemoHandler.extension = original_extension
def test_main_request_routes_live_nfc_peripheral_to_gscu(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
+44 -3
View File
@@ -35,7 +35,7 @@ def test_unified_console_exposes_nfc_discovery_and_learning_flow() -> None:
assert 'sendGatewayCommand("read", { slot: learning.draft.slot })' in script
assert 'function infraredSignalFromResponse(value)' in script
assert 'function infraredFingerprint(signal)' in script
assert '学习回执成功,但物理槽位回读为空' in script
assert '学习指令已下发,但物理槽位回读为空' in script
assert 'signalSource: "n7web"' in script
assert 'if (capability.signalSource === "n7web") return' in script
assert '建立档案并开始学习' in script
@@ -49,8 +49,49 @@ def test_unified_console_exposes_nfc_discovery_and_learning_flow() -> None:
assert '.v2-nfc-inline' in styles
assert '.v2-nfc-dialog' in styles
assert '.v2-nfc-capability-grid' in styles
assert 'self-extension.js?v=nfc-toy-33' in html
assert 'self-extension.css?v=nfc-toy-33' in html
assert 'self-extension.js?v=gscu-tcp-37' in html
assert 'self-extension.css?v=gscu-tcp-37' in html
def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
script = source("self-extension.js")
styles = source("self-extension.css")
assert 'id="extensionGscuTcpHost"' in script
assert 'id="extensionGscuTcpPort"' in script
assert 'id="extensionGscuCommandName"' in script
assert 'id="extensionGscuNamedCommands"' in script
assert 'data-open-gscu-tcp' in script
assert 'api("gscu/config"' in script
assert 'api("gscu/slots")' in script
assert 'method: "PUT"' in script
assert 'method: "DELETE"' in script
assert '"gscu/erase-all"' in script
assert 'verification: "slot_readback"' in script
assert "学习指令已下发,但槽位回读失败" in script
assert "确认擦除 GSCU1B 中的全部红外码吗" in script
assert "GSCU1B 设备中的该红外码不会被擦除" in script
assert "function upsertNamedInfraredCommand(slot, name" in script
assert "button.disabled = true;" in script
assert "buttons.forEach((item) => { item.disabled = true; });" not in script
for action in (
"info",
"learn",
"cancel",
"emit",
"read",
"write",
"restart",
"wifi-lock",
"erase",
"reset",
"raw",
):
assert f'data-gscu-action="{action}"' in script
assert ".v2-gscu-tcp-dialog" in styles
assert ".v2-gscu-config-form" in styles
assert "#extensionGscuTcpResult" in styles
assert "#extensionGscuNamedCommands" in styles
def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None:
+273 -3
View File
@@ -1,23 +1,27 @@
from __future__ import annotations
from collections.abc import Callable
from copy import deepcopy
import json
import os
import socket
import threading
import time
import unittest
from collections.abc import Callable
from copy import deepcopy
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import MagicMock, Mock, patch
from cognitive_agent.application.self_extension import (
ExtensionIntegrationError,
GatewayError,
MqttGateway,
SelfExtensionService,
SimulationGateway,
TcpGateway,
active_peripheral_plan_catalog,
build_gateway_command,
create_gateway,
resolve_active_peripheral_command,
validate_slot,
)
@@ -39,7 +43,34 @@ class SelfExtensionTests(unittest.TestCase):
self.assertEqual(command["type"], "infrared")
self.assertEqual(command["action"], "learn")
self.assertEqual(command["data"], {"no": 17})
self.assertTrue(str(command["messageId"]).startswith("bridge-"))
self.assertNotIn("messageId", command)
self.assertEqual(
build_gateway_command("emit", {"slot": 17}),
{"action": "emit", "data": {"no": 17}, "type": "infrared"},
)
self.assertEqual(
build_gateway_command("cancel", {}),
{"action": "learnCancel", "type": "infrared"},
)
read_command = build_gateway_command("read", {"slot": 17})
self.assertEqual(read_command["action"], "ir_read")
self.assertEqual(read_command["data"], {"no": 17})
self.assertTrue(str(read_command["messageId"]).startswith("bridge-"))
write_command = build_gateway_command(
"write", {"slot": 17, "ir_data": "sample-infrared-data"}
)
self.assertEqual(write_command["action"], "ir_write")
self.assertEqual(write_command["no"], 17)
self.assertEqual(write_command["ir_data"], "sample-infrared-data")
self.assertEqual(
build_gateway_command("restart", {}),
{"system": "restart", "type": "setting"},
)
self.assertEqual(
build_gateway_command("wifi_lock", {"state": 1}),
{"wifiLock": 1, "type": "setting"},
)
self.assertEqual(validate_slot("248"), 248)
with self.assertRaises(GatewayError):
@@ -47,6 +78,245 @@ class SelfExtensionTests(unittest.TestCase):
with self.assertRaises(GatewayError):
build_gateway_command("erase", {"confirm": "wrong"})
def test_infrared_slot_catalog_is_durable_sorted_and_device_safe(self) -> None:
with TemporaryDirectory() as directory:
database_path = Path(directory, "infrared-slots.sqlite3")
service = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
slot_12 = service.save_infrared_slot(
12, {"name": "电视开关", "source": "tcp"}
)["slot"]
service.save_infrared_slot(3, {"name": "空调制冷", "source": "tcp"})
renamed = service.save_infrared_slot(
12, {"name": "电视电源", "source": "tcp"}
)["slot"]
self.assertEqual(
[item["slot"] for item in service.list_infrared_slots()["slots"]],
[3, 12],
)
self.assertEqual(renamed["name"], "电视电源")
self.assertEqual(renamed["learned_at"], slot_12["learned_at"])
reloaded = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
self.assertEqual(reloaded.list_infrared_slots()["count"], 2)
deleted = reloaded.delete_infrared_slot(3)
self.assertTrue(deleted["removed"])
self.assertFalse(deleted["device_signal_erased"])
self.assertEqual(
[item["slot"] for item in reloaded.list_infrared_slots()["slots"]],
[12],
)
def test_erase_all_clears_catalog_only_after_valid_device_submission(self) -> None:
with TemporaryDirectory() as directory:
service = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
)
service.save_infrared_slot(8, {"name": "玩具启动"})
with self.assertRaisesRegex(GatewayError, "ERASE_ALL"):
service.erase_all_infrared({"confirm": "wrong"})
self.assertEqual(service.list_infrared_slots()["count"], 1)
result = service.erase_all_infrared({"confirm": "ERASE_ALL"})
self.assertTrue(result["catalog_cleared"])
self.assertEqual(result["catalog_removed_count"], 1)
self.assertEqual(service.list_infrared_slots()["count"], 0)
def test_mqtt_gateway_defaults_to_local_infrared_topics(self) -> None:
mqtt_variables = {
"GSCU_MQTT_HOST",
"GSCU_MQTT_PORT",
"GSCU_COMMAND_TOPIC",
"GSCU_RESPONSE_TOPIC",
"COGNITIVE_AGENT_VEHICLE_MQTT_HOST",
"COGNITIVE_AGENT_VEHICLE_MQTT_PORT",
}
environment = {
key: value for key, value in os.environ.items() if key not in mqtt_variables
}
with patch.dict(os.environ, environment, clear=True):
gateway = MqttGateway()
self.assertEqual(gateway.host, "127.0.0.1")
self.assertEqual(gateway.port, 1883)
self.assertEqual(gateway.command_topic, "topic/from_n7")
self.assertEqual(gateway.response_topic, "topic/from_infrared")
self.assertEqual(
gateway.status(),
{
"transport": "mqtt",
"connected": False,
"broker": "127.0.0.1:1883",
"command_topic": "topic/from_n7",
"response_topic": "topic/from_infrared",
"qos": 0,
},
)
def test_default_gateway_transport_is_tcp(self) -> None:
with TemporaryDirectory() as directory:
with patch.dict(
os.environ,
{"GSCU_RUNTIME_CONFIG_PATH": f"{directory}/gateway.json"},
clear=True,
):
gateway = create_gateway()
self.assertIsInstance(gateway, TcpGateway)
self.assertEqual(gateway.listen_host, "0.0.0.0")
self.assertEqual(gateway.port, 4444)
def test_tcp_gateway_runtime_configuration_is_persisted_and_reloaded(self) -> None:
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
probe.bind(("127.0.0.1", 0))
port = int(probe.getsockname()[1])
probe.close()
service = SelfExtensionService(SimulationGateway())
with TemporaryDirectory() as directory:
config_path = f"{directory}/gateway.json"
with patch.dict(
os.environ,
{"GSCU_RUNTIME_CONFIG_PATH": config_path},
clear=True,
):
service.start()
try:
result = service.configure_tcp_gateway(
{"listen_host": "127.0.0.1", "port": port}
)
self.assertEqual(result["transport"], "tcp")
self.assertEqual(result["listen_host"], "127.0.0.1")
self.assertEqual(result["port"], port)
self.assertTrue(
self._wait_until(lambda: service.health().get("listening")),
service.health(),
)
saved = json.loads(Path(config_path).read_text(encoding="utf-8"))
self.assertEqual(
saved,
{
"transport": "tcp",
"listen_host": "127.0.0.1",
"port": port,
},
)
reloaded = create_gateway()
self.assertIsInstance(reloaded, TcpGateway)
self.assertEqual(reloaded.listen_host, "127.0.0.1")
self.assertEqual(reloaded.port, port)
finally:
service.close()
def test_tcp_gateway_runtime_configuration_rejects_invalid_values(self) -> None:
service = SelfExtensionService(SimulationGateway())
with self.assertRaisesRegex(GatewayError, "IPv4"):
service.configure_tcp_gateway({"listen_host": "not-an-ip", "port": 4444})
with self.assertRaisesRegex(GatewayError, "1–65535"):
service.configure_tcp_gateway({"listen_host": "0.0.0.0", "port": 70000})
def test_tcp_gateway_submits_verified_learning_protocol_without_ack(self) -> None:
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
probe.bind(("127.0.0.1", 0))
port = int(probe.getsockname()[1])
probe.close()
gateway = TcpGateway("127.0.0.1", port)
service = SelfExtensionService(gateway)
service.command_timeout = 2
received: list[dict[str, object]] = []
device_finished = threading.Event()
def run_device() -> None:
connection = socket.create_connection(("127.0.0.1", port), timeout=2)
try:
payload = b""
while b"\n" not in payload:
payload += connection.recv(4096)
received.append(json.loads(payload.split(b"\n", 1)[0]))
time.sleep(0.3)
finally:
connection.close()
device_finished.set()
service.start()
try:
self.assertTrue(self._wait_until(lambda: gateway.status()["listening"]))
device_thread = threading.Thread(target=run_device, daemon=True)
device_thread.start()
self.assertTrue(self._wait_until(lambda: gateway.status()["connected"]))
started = time.monotonic()
result = service.execute_gateway("learn", {"slot": 23})
elapsed = time.monotonic() - started
self.assertTrue(device_finished.wait(timeout=2))
self.assertEqual(
received,
[
{
"action": "learn",
"data": {"no": 23},
"type": "infrared",
}
],
)
self.assertLess(elapsed, 0.5)
self.assertEqual(result["device_response"]["delivery_status"], "submitted")
self.assertTrue(result["device_response"]["transport_submitted"])
self.assertFalse(result["learning_capture_confirmed"])
self.assertIn("槽位回读确认", result["message"])
finally:
service.close()
def test_tcp_gateway_returns_immediately_for_command_without_device_ack(
self,
) -> None:
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
probe.bind(("127.0.0.1", 0))
port = int(probe.getsockname()[1])
probe.close()
gateway = TcpGateway("127.0.0.1", port)
service = SelfExtensionService(gateway)
service.command_timeout = 2
received = threading.Event()
def run_device() -> None:
connection = socket.create_connection(("127.0.0.1", port), timeout=2)
try:
payload = b""
while b"\n" not in payload:
payload += connection.recv(4096)
command = json.loads(payload.split(b"\n", 1)[0])
self.assertEqual(
command,
{"action": "emit", "data": {"no": 9}, "type": "infrared"},
)
received.set()
time.sleep(0.3)
finally:
connection.close()
service.start()
try:
self.assertTrue(self._wait_until(lambda: gateway.status()["listening"]))
device_thread = threading.Thread(target=run_device, daemon=True)
device_thread.start()
self.assertTrue(self._wait_until(lambda: gateway.status()["connected"]))
started = time.monotonic()
result = service.execute_gateway("emit", {"slot": 9})
elapsed = time.monotonic() - started
self.assertTrue(received.wait(timeout=1))
self.assertLess(elapsed, 0.5)
self.assertEqual(
result["device_response"]["delivery_status"], "submitted"
)
self.assertTrue(result["device_response"]["transport_submitted"])
self.assertFalse(result["gateway_acknowledged"])
finally:
service.close()
def test_simulation_gateway_executes_without_physical_confirmation(self) -> None:
service = SelfExtensionService(SimulationGateway())
result = service.execute_gateway("emit", {"slot": 3, "repeat": 2})