添加红外扩展

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
+404 -41
View File
@@ -7,10 +7,14 @@ import os
import queue
import re
import socket
import sqlite3
import ssl
import threading
import time
import uuid
from contextlib import closing
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
@@ -24,7 +28,6 @@ from cognitive_agent.infrastructure.llm.openai_responses import (
OpenAIIntentInterpreter,
)
SLOT_MIN = 1
SLOT_MAX = 248
MAX_CATALOG_ITEMS = 248
@@ -32,6 +35,21 @@ MAX_PLAN_STEPS = 12
MIN_PLAN_DELAY_MS = 1
MAX_PLAN_DELAY_MS = 30_000
MAX_TOTAL_PLAN_DELAY_MS = 60_000
DEFAULT_GSCU_MQTT_HOST = "127.0.0.1"
DEFAULT_GSCU_MQTT_PORT = 1883
DEFAULT_GSCU_COMMAND_TOPIC = "topic/from_n7"
DEFAULT_GSCU_RESPONSE_TOPIC = "topic/from_infrared"
DEFAULT_GSCU_TCP_LISTEN_HOST = "0.0.0.0"
DEFAULT_GSCU_TCP_PORT = 4444
DEFAULT_GSCU_RUNTIME_CONFIG_PATH = (
Path(__file__).resolve().parents[3] / "artifacts" / "tmp" / "gscu_gateway.json"
)
DEFAULT_GSCU_IR_CATALOG_DB_PATH = (
Path(__file__).resolve().parents[3]
/ "artifacts"
/ "server"
/ "gscu_ir_catalog.sqlite3"
)
_ACTION_SYNONYM_GROUPS = (
@@ -64,6 +82,178 @@ def _env_bool(name: str, default: bool = False) -> bool:
return value.strip().lower() in {"1", "true", "yes", "on"}
def validate_tcp_listen_host(value: Any) -> str:
host = str(value or "").strip()
if not host or len(host) > 45:
raise GatewayError("TCP 监听 IP 不能为空。")
try:
socket.inet_pton(socket.AF_INET, host)
except OSError as exc:
raise GatewayError("TCP 监听地址必须是有效的 IPv4 地址,例如 0.0.0.0。") from exc
return host
def validate_tcp_port(value: Any) -> int:
try:
port = int(value)
except (TypeError, ValueError) as exc:
raise GatewayError("TCP 监听端口必须是整数。") from exc
if not 1 <= port <= 65535:
raise GatewayError("TCP 监听端口范围是 1–65535。")
return port
def _runtime_gateway_config_path() -> Path:
configured = os.getenv("GSCU_RUNTIME_CONFIG_PATH", "").strip()
return Path(configured).expanduser() if configured else DEFAULT_GSCU_RUNTIME_CONFIG_PATH
def _load_runtime_gateway_config() -> dict[str, Any]:
path = _runtime_gateway_config_path()
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def _save_runtime_gateway_config(payload: dict[str, Any]) -> None:
path = _runtime_gateway_config_path()
temporary = path.with_suffix(f"{path.suffix}.tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
except OSError as exc:
raise GatewayError(f"保存红外 TCP 配置失败:{exc}") from exc
def _infrared_catalog_db_path() -> Path:
configured = os.getenv("GSCU_IR_CATALOG_DB_PATH", "").strip()
return Path(configured).expanduser() if configured else DEFAULT_GSCU_IR_CATALOG_DB_PATH
class InfraredSlotCatalog:
"""Durable metadata for infrared slots learned by GSCU1B."""
def __init__(self, database_path: str | Path | None = None) -> None:
self.database_path = Path(database_path or _infrared_catalog_db_path()).expanduser()
self._lock = threading.RLock()
self._initialize()
@staticmethod
def _timestamp() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds")
@staticmethod
def _record(row: sqlite3.Row) -> dict[str, Any]:
return {
"slot": int(row["slot"]),
"name": str(row["name"]),
"source": str(row["source"]),
"learned_at": str(row["learned_at"]),
"updated_at": str(row["updated_at"]),
}
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=5.0)
connection.row_factory = sqlite3.Row
return connection
def _initialize(self) -> None:
try:
self.database_path.parent.mkdir(parents=True, exist_ok=True)
with closing(self._connect()) as connection, connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS infrared_slots (
slot INTEGER PRIMARY KEY CHECK(slot BETWEEN 1 AND 248),
name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 48),
source TEXT NOT NULL DEFAULT 'tcp',
learned_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
except (OSError, sqlite3.Error) as exc:
raise GatewayError(f"初始化红外槽位数据库失败:{exc}") from exc
def list_slots(self) -> list[dict[str, Any]]:
with self._lock:
try:
with closing(self._connect()) as connection:
rows = connection.execute(
"SELECT slot, name, source, learned_at, updated_at "
"FROM infrared_slots ORDER BY slot ASC"
).fetchall()
except sqlite3.Error as exc:
raise GatewayError(f"读取红外槽位数据库失败:{exc}") from exc
return [self._record(row) for row in rows]
def upsert(self, slot: Any, name: Any, source: Any = "tcp") -> dict[str, Any]:
normalized_slot = validate_slot(slot)
normalized_name = str(name or "").strip()
if not normalized_name:
raise GatewayError("红外信号名称不能为空。")
if len(normalized_name) > 48:
raise GatewayError("红外信号名称不能超过 48 个字符。")
normalized_source = str(source or "tcp").strip()[:32] or "tcp"
now = self._timestamp()
with self._lock:
try:
with closing(self._connect()) as connection, connection:
connection.execute(
"""
INSERT INTO infrared_slots (
slot, name, source, learned_at, updated_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(slot) DO UPDATE SET
name = excluded.name,
source = excluded.source,
updated_at = excluded.updated_at
""",
(normalized_slot, normalized_name, normalized_source, now, now),
)
row = connection.execute(
"SELECT slot, name, source, learned_at, updated_at "
"FROM infrared_slots WHERE slot = ?",
(normalized_slot,),
).fetchone()
except sqlite3.Error as exc:
raise GatewayError(f"保存红外槽位失败:{exc}") from exc
assert row is not None
return self._record(row)
def delete(self, slot: Any) -> bool:
normalized_slot = validate_slot(slot)
with self._lock:
try:
with closing(self._connect()) as connection, connection:
cursor = connection.execute(
"DELETE FROM infrared_slots WHERE slot = ?", (normalized_slot,)
)
return cursor.rowcount > 0
except sqlite3.Error as exc:
raise GatewayError(f"删除红外槽位记录失败:{exc}") from exc
def clear(self) -> int:
with self._lock:
try:
with closing(self._connect()) as connection, connection:
count = int(
connection.execute(
"SELECT COUNT(*) FROM infrared_slots"
).fetchone()[0]
)
connection.execute("DELETE FROM infrared_slots")
return count
except sqlite3.Error as exc:
raise GatewayError(f"清空红外槽位数据库失败:{exc}") from exc
def _vehicle_catalog_commands(vehicle_devices: list[dict[str, Any]]) -> tuple[str, ...]:
"""Return the exact, UI-declared dual-channel vehicle commands."""
@@ -424,29 +614,60 @@ def active_peripheral_plan_catalog(
def build_gateway_command(action: str, payload: dict[str, Any]) -> dict[str, Any]:
request_id = _message_id()
slot_mode = _env_text("GSCU_SLOT_FIELD_MODE", "data").lower()
if slot_mode not in {"data", "top_level"}:
raise GatewayError("GSCU_SLOT_FIELD_MODE 只能是 data 或 top_level。")
if action == "info":
return {"type": "info", "messageId": request_id}
if action == "cancel":
return {"type": "infrared", "action": "learnCancel", "messageId": request_id}
return {"action": "learnCancel", "type": "infrared"}
if action == "erase":
if payload.get("confirm") != "ERASE_ALL":
raise GatewayError("全量擦除需要明确确认 ERASE_ALL。")
return {"type": "infrared", "action": "erase", "messageId": request_id}
return {"action": "erase", "type": "infrared"}
if action in {"learn", "emit", "read"}:
slot = validate_slot(payload.get("slot"))
command: dict[str, Any] = {
"type": "infrared",
"action": "ir_read" if action == "read" else action,
"messageId": request_id,
"type": "infrared",
}
if action == "read" or slot_mode == "data":
command["data"] = {"no": slot}
else:
command["no"] = slot
if action == "read":
command["messageId"] = request_id
command["data"] = {"no": slot}
return command
if action == "write":
slot = validate_slot(payload.get("slot"))
ir_data = str(payload.get("ir_data", "")).strip()
if not ir_data or len(ir_data) > 240_000:
raise GatewayError("写入红外码不能为空且不能超过 240000 个字符。")
return {
"action": "ir_write",
"ir_data": ir_data,
"messageId": request_id,
"no": slot,
"type": "infrared",
}
if action == "restart":
return {"system": "restart", "type": "setting"}
if action == "reset":
if payload.get("confirm") != "RESET_DEVICE":
raise GatewayError("恢复出厂设置需要明确确认 RESET_DEVICE。")
return {"system": "reset", "type": "setting"}
if action == "wifi_lock":
try:
state = int(payload.get("state"))
except (TypeError, ValueError) as exc:
raise GatewayError("WiFi 配网锁状态只能是 0 或 1。") from exc
if state not in {0, 1}:
raise GatewayError("WiFi 配网锁状态只能是 0 或 1。")
return {"wifiLock": state, "type": "setting"}
if action == "raw":
raw = payload.get("command")
if not isinstance(raw, dict) or not raw:
raise GatewayError("原始指令必须是非空 JSON 对象。")
encoded = json.dumps(raw, ensure_ascii=False)
if len(encoded.encode("utf-8")) > 240_000:
raise GatewayError("原始指令不能超过 240000 字节。")
command = dict(raw)
command.setdefault("messageId", request_id)
return command
raise GatewayError(f"不支持的设备操作:{action}")
@@ -457,6 +678,16 @@ def _response_matches(payload: Any, expected_message_id: str) -> bool:
return payload.get("messageId") in {None, expected_message_id}
def _tcp_command_requires_response(command: dict[str, Any]) -> bool:
"""Only block for GSCU1B operations whose result is consumed by the UI."""
# Firmware 1.1.5 accepts ``learn`` and writes the captured signal to the
# requested slot, but does not publish a spontaneous learning ACK. The
# verified CLI therefore treats learn like a submitted command. The UI
# confirms completion separately with ``ir_read``.
return command.get("type") == "info" or command.get("action") == "ir_read"
class BaseGateway:
transport = "unknown"
@@ -512,9 +743,21 @@ class UnavailableGateway(BaseGateway):
class TcpGateway(BaseGateway):
transport = "tcp"
def __init__(self) -> None:
self.listen_host = _env_text("GSCU_TCP_LISTEN_HOST", "0.0.0.0")
self.port = int(_env_text("GSCU_TCP_PORT", "4444"))
def __init__(
self,
listen_host: str | None = None,
port: int | None = None,
) -> None:
self.listen_host = validate_tcp_listen_host(
listen_host
if listen_host is not None
else _env_text("GSCU_TCP_LISTEN_HOST", DEFAULT_GSCU_TCP_LISTEN_HOST)
)
self.port = validate_tcp_port(
port
if port is not None
else _env_text("GSCU_TCP_PORT", str(DEFAULT_GSCU_TCP_PORT))
)
self.bind_retry_interval = max(
0.05,
float(_env_text("GSCU_TCP_BIND_RETRY_SECONDS", "1.0")),
@@ -577,6 +820,8 @@ class TcpGateway(BaseGateway):
"listening": listening,
"peer_connected": connected,
"listen": f"{self.listen_host}:{self.port}",
"listen_host": self.listen_host,
"port": self.port,
"peer": (
f"{peer_address[0]}:{peer_address[1]}" if peer_address else None
),
@@ -785,6 +1030,15 @@ class TcpGateway(BaseGateway):
except OSError:
pass
raise GatewayError(f"GSCU1B TCP 发送失败:{exc}") from exc
if not _tcp_command_requires_response(command):
return {
"success": True,
"message": "COMMAND_SUBMITTED",
"delivery_status": "submitted",
"transport_submitted": True,
"gateway_acknowledged": False,
"physical_state_confirmed": False,
}
return self._wait_for_response(command, timeout)
def _wait_for_response(self, command: dict[str, Any], timeout: float) -> dict[str, Any]:
@@ -797,9 +1051,7 @@ class TcpGateway(BaseGateway):
break
if _response_matches(payload, expected_id):
return payload if isinstance(payload, dict) else {"message": payload}
if command.get("action") == "learn":
raise GatewayError("学习指令已发送,但未在超时前收到红外捕获回执。")
return {
result = {
"success": True,
"message": "COMMAND_SENT_WITHOUT_DEVICE_ACK",
"gateway_acknowledged": False,
@@ -816,12 +1068,20 @@ class MqttGateway(BaseGateway):
except ImportError as exc:
raise GatewayError("MQTT 模式需要安装 paho-mqtt。") from exc
self.mqtt = mqtt
self.host = _env_text("GSCU_MQTT_HOST", "mqtt.geek-smart.cn")
self.port = int(_env_text("GSCU_MQTT_PORT", "1883"))
self.command_topic = os.getenv("GSCU_COMMAND_TOPIC", "").strip()
self.response_topic = os.getenv("GSCU_RESPONSE_TOPIC", "").strip()
if not self.command_topic or not self.response_topic:
raise GatewayError("MQTT 模式必须配置 GSCU_COMMAND_TOPIC 和 GSCU_RESPONSE_TOPIC。")
shared_host = _env_text(
"COGNITIVE_AGENT_VEHICLE_MQTT_HOST", DEFAULT_GSCU_MQTT_HOST
)
shared_port = _env_text(
"COGNITIVE_AGENT_VEHICLE_MQTT_PORT", str(DEFAULT_GSCU_MQTT_PORT)
)
self.host = _env_text("GSCU_MQTT_HOST", shared_host)
self.port = int(_env_text("GSCU_MQTT_PORT", shared_port))
self.command_topic = _env_text(
"GSCU_COMMAND_TOPIC", DEFAULT_GSCU_COMMAND_TOPIC
)
self.response_topic = _env_text(
"GSCU_RESPONSE_TOPIC", DEFAULT_GSCU_RESPONSE_TOPIC
)
self.qos = int(_env_text("GSCU_MQTT_QOS", "0"))
self._connected = threading.Event()
self._responses: queue.Queue[dict[str, Any] | str] = queue.Queue()
@@ -855,6 +1115,9 @@ class MqttGateway(BaseGateway):
"transport": self.transport,
"connected": self._connected.is_set(),
"broker": f"{self.host}:{self.port}",
"command_topic": self.command_topic,
"response_topic": self.response_topic,
"qos": self.qos,
}
def _on_connect(
@@ -930,11 +1193,29 @@ class MqttGateway(BaseGateway):
def create_gateway() -> BaseGateway:
transport = _env_text("GSCU_TRANSPORT", "tcp").lower()
runtime_config = _load_runtime_gateway_config()
transport = _env_text(
"GSCU_TRANSPORT", str(runtime_config.get("transport") or "tcp")
).lower()
if transport == "simulation":
return SimulationGateway()
if transport == "tcp":
return TcpGateway()
runtime_host = runtime_config.get("listen_host")
runtime_port = runtime_config.get("port")
listen_host = (
os.getenv("GSCU_TCP_LISTEN_HOST", "").strip()
or runtime_host
or DEFAULT_GSCU_TCP_LISTEN_HOST
)
port = (
os.getenv("GSCU_TCP_PORT", "").strip()
or runtime_port
or DEFAULT_GSCU_TCP_PORT
)
try:
return TcpGateway(str(listen_host), validate_tcp_port(port))
except GatewayError as exc:
return UnavailableGateway("tcp", str(exc))
if transport == "mqtt":
try:
return MqttGateway()
@@ -946,8 +1227,16 @@ def create_gateway() -> BaseGateway:
class SelfExtensionService:
"""Own the GSCU1B gateway and hybrid GPT/vehicle orchestration boundary."""
def __init__(self, gateway: BaseGateway | None = None) -> None:
def __init__(
self,
gateway: BaseGateway | None = None,
*,
catalog_db_path: str | Path | None = None,
) -> None:
self.gateway = gateway or create_gateway()
self.infrared_catalog = InfraredSlotCatalog(catalog_db_path)
self._gateway_lock = threading.RLock()
self._started = False
self.command_timeout = float(_env_text("GSCU_COMMAND_TIMEOUT", "15"))
self.openai_base_url = _env_text(
"OPENAI_BASE_URL", "https://api.openai.com/v1"
@@ -966,15 +1255,21 @@ class SelfExtensionService:
self.vehicle_id = _env_text("COGNITIVE_AGENT_VEHICLE_ID", "denza_n7")
def start(self) -> None:
self.gateway.start()
with self._gateway_lock:
self._started = True
self.gateway.start()
def close(self) -> None:
self.gateway.close()
with self._gateway_lock:
self._started = False
self.gateway.close()
def health(self) -> dict[str, Any]:
with self._gateway_lock:
gateway_status = self.gateway.status()
return {
"success": True,
**self.gateway.status(),
**gateway_status,
"gpt": {
"configured": bool(os.getenv("OPENAI_API_KEY", "").strip()),
"model": self.openai_model,
@@ -988,6 +1283,60 @@ class SelfExtensionService:
},
}
def configure_tcp_gateway(self, payload: dict[str, Any]) -> dict[str, Any]:
listen_host = validate_tcp_listen_host(payload.get("listen_host"))
port = validate_tcp_port(payload.get("port"))
config = {
"transport": "tcp",
"listen_host": listen_host,
"port": port,
}
_save_runtime_gateway_config(config)
replacement = TcpGateway(listen_host, port)
with self._gateway_lock:
previous = self.gateway
previous.close()
self.gateway = replacement
if self._started:
replacement.start()
return self.health()
def list_infrared_slots(self) -> dict[str, Any]:
slots = self.infrared_catalog.list_slots()
return {"success": True, "slots": slots, "count": len(slots)}
def save_infrared_slot(self, slot: Any, payload: dict[str, Any]) -> dict[str, Any]:
record = self.infrared_catalog.upsert(
slot,
payload.get("name"),
payload.get("source", "tcp"),
)
return {"success": True, "slot": record}
def delete_infrared_slot(self, slot: Any) -> dict[str, Any]:
removed = self.infrared_catalog.delete(slot)
return {
"success": True,
"removed": removed,
"slot": validate_slot(slot),
"device_signal_erased": False,
"message": (
"本地槽位记录已删除;GSCU1B 中的红外码未被擦除。"
if removed
else "本地没有该槽位记录;GSCU1B 中的红外码未被擦除。"
),
}
def erase_all_infrared(self, payload: dict[str, Any]) -> dict[str, Any]:
result = self.execute_gateway("erase", payload)
removed = self.infrared_catalog.clear()
return {
**result,
"catalog_cleared": True,
"catalog_removed_count": removed,
"message": "设备全部擦除命令已提交,本地红外槽位目录已同步清空。",
}
def execute_gateway(self, action: str, payload: dict[str, Any]) -> dict[str, Any]:
repeat = int(payload.get("repeat", 1))
interval_ms = int(payload.get("interval_ms", 250))
@@ -997,25 +1346,39 @@ class SelfExtensionService:
raise GatewayError("重复发射间隔范围是 100-5000 ms。")
command = build_gateway_command(action, payload)
responses: list[dict[str, Any]] = []
for index in range(repeat):
current = command if index == 0 else {**command, "messageId": _message_id()}
response = self.gateway.send(current, self.command_timeout)
if response.get("success") is False:
raise GatewayError(str(response.get("message") or "设备返回失败。"))
responses.append(response)
if index + 1 < repeat:
time.sleep(interval_ms / 1000.0)
return {
with self._gateway_lock:
gateway = self.gateway
for index in range(repeat):
current = command
if index > 0 and "messageId" in command:
current = {**command, "messageId": _message_id()}
response = gateway.send(current, self.command_timeout)
if response.get("success") is False:
raise GatewayError(str(response.get("message") or "设备返回失败。"))
responses.append(response)
if index + 1 < repeat:
time.sleep(interval_ms / 1000.0)
learn_submitted = action == "learn" and any(
item.get("delivery_status") == "submitted" for item in responses
)
result = {
"success": True,
"transport": self.gateway.transport,
"transport": gateway.transport,
"request": command,
"device_response": responses[0] if repeat == 1 else responses,
"gateway_acknowledged": all(
item.get("gateway_acknowledged", True) for item in responses
),
"physical_state_confirmed": False,
"message": "学习器指令处理完成;玩具物理状态需要独立确认。",
"message": (
"学习指令已提交;设备固件不主动返回学习 ACK,请通过槽位回读确认。"
if learn_submitted
else "学习器指令处理完成;玩具物理状态需要独立确认。"
),
}
if learn_submitted:
result["learning_capture_confirmed"] = False
return result
def execute_peripheral_request(
self,