添加红外扩展
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -315,6 +315,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
if path == "/api/extension/nfc/status":
|
||||
self._json({"success": True, **self.nfc.snapshot()})
|
||||
return
|
||||
if path == "/api/extension/gscu/slots":
|
||||
try:
|
||||
self._json(self.extension.list_infrared_slots())
|
||||
except GatewayError as exc:
|
||||
self._json(
|
||||
{"success": False, "error": "catalog_unavailable", "message": str(exc)},
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
return
|
||||
if path == "/api/extension/gscu/info":
|
||||
self._handle_extension_gateway("info", {})
|
||||
return
|
||||
@@ -902,11 +911,44 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
def do_PUT(self) -> None: # noqa: N802 - stdlib handler API
|
||||
path = unquote(urlparse(self.path).path)
|
||||
if not self._authorize(path):
|
||||
return
|
||||
match = re.fullmatch(r"/api/extension/gscu/slots/(?P<slot>\d+)", path)
|
||||
if match is None:
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=4096)
|
||||
self._json(
|
||||
self.extension.save_infrared_slot(match.group("slot"), payload),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except (GatewayError, ValueError, json.JSONDecodeError) as exc:
|
||||
self._json(
|
||||
{"success": False, "error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
def do_DELETE(self) -> None: # noqa: N802 - stdlib handler API
|
||||
parsed_url = urlparse(self.path)
|
||||
path = unquote(parsed_url.path)
|
||||
if not self._authorize(path):
|
||||
return
|
||||
slot_match = re.fullmatch(r"/api/extension/gscu/slots/(?P<slot>\d+)", path)
|
||||
if slot_match is not None:
|
||||
try:
|
||||
self._json(
|
||||
self.extension.delete_infrared_slot(slot_match.group("slot")),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except GatewayError as exc:
|
||||
self._json(
|
||||
{"success": False, "error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
return
|
||||
prefix = "/api/mode-profiles/"
|
||||
if not path.startswith(prefix):
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
@@ -966,18 +1008,46 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _handle_extension_post(self, path: str) -> None:
|
||||
action_map = {
|
||||
"/api/extension/gscu/info": "info",
|
||||
"/api/extension/gscu/learn": "learn",
|
||||
"/api/extension/gscu/cancel": "cancel",
|
||||
"/api/extension/gscu/emit": "emit",
|
||||
"/api/extension/gscu/read": "read",
|
||||
"/api/extension/gscu/erase": "erase",
|
||||
"/api/extension/gscu/write": "write",
|
||||
"/api/extension/gscu/restart": "restart",
|
||||
"/api/extension/gscu/reset": "reset",
|
||||
"/api/extension/gscu/wifi-lock": "wifi_lock",
|
||||
"/api/extension/gscu/raw": "raw",
|
||||
}
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=256 * 1024)
|
||||
if path in {
|
||||
"/api/extension/gscu/erase",
|
||||
"/api/extension/gscu/erase-all",
|
||||
}:
|
||||
result = self.extension.erase_all_infrared(dict(payload))
|
||||
self.vehicle_display_sync.update_extension(
|
||||
action={"type": "erase_all"},
|
||||
result=result,
|
||||
)
|
||||
self._json(result)
|
||||
return
|
||||
action = action_map.get(path)
|
||||
if action:
|
||||
self._handle_extension_gateway(action, payload)
|
||||
return
|
||||
if path == "/api/extension/gscu/config":
|
||||
result = self.extension.configure_tcp_gateway(dict(payload))
|
||||
self.vehicle_display_sync.update_extension(
|
||||
action={
|
||||
"type": "gateway_config",
|
||||
"listen_host": result.get("listen_host"),
|
||||
"port": result.get("port"),
|
||||
},
|
||||
result=result,
|
||||
)
|
||||
self._json(result)
|
||||
return
|
||||
if path == "/api/extension/display-sync":
|
||||
catalog = payload.get("peripherals", payload.get("catalog", []))
|
||||
if not isinstance(catalog, list) or len(catalog) > 248:
|
||||
|
||||
Reference in New Issue
Block a user