feat: integrate learned peripherals into mode creation

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.
This commit is contained in:
Miayui
2026-09-20 16:37:19 +08:00
parent 904f5f8a6c
commit bfff1b515c
25 changed files with 3073 additions and 102 deletions
@@ -2,6 +2,7 @@
from __future__ import annotations
import hashlib
import json
import os
import queue
@@ -119,6 +120,10 @@ class GatewayError(RuntimeError):
"""Raised when a GSCU1B command cannot be delivered safely."""
class PeripheralCatalogConflictError(GatewayError):
"""Raised when a browser tries to replace a newer durable IR catalog."""
class ExtensionIntegrationError(RuntimeError):
"""Raised when GPT planning or vehicle orchestration is unavailable."""
@@ -1037,7 +1042,10 @@ def _peripheral_execution_identity_authorized(
) -> bool:
# Saved modes execute an immutable, previously validated command flow and
# are rebound to the same persisted NFC/device/slot tuple before reaching here.
if capability.get("parse_source") == "saved_mode_command_flow":
if capability.get("parse_source") in {
"saved_mode_command_flow",
"scene_composer_capability_ref",
}:
return True
return bool(_device_match_score(_compact_match_text(utterance), capability))
@@ -1072,6 +1080,9 @@ def _normalize_peripheral_capability(value: Any) -> dict[str, Any]:
raise GatewayError("红外能力风险等级只能是 low、medium 或 high。")
if value.get("status", "learned") != "learned":
raise GatewayError("只有已学习的红外能力可以进入执行白名单。")
for field_name in ("scene_safe", "idempotent"):
if value.get(field_name) is not None and type(value[field_name]) is not bool:
raise GatewayError(f"红外能力字段 {field_name} 必须是布尔值。")
device_aliases = _normalized_aliases(
value.get("device_aliases"), field="设备别名"
@@ -1123,6 +1134,8 @@ def _normalize_peripheral_capability(value: Any) -> dict[str, Any]:
"aliases": action_aliases,
"action_names": action_names,
"risk": risk,
"scene_safe": value.get("scene_safe"),
"idempotent": value.get("idempotent"),
"slot": validate_slot(value.get("slot")),
"transport": str(value.get("transport") or "gscu_tcp")[:32],
}
@@ -2310,6 +2323,7 @@ class SelfExtensionService:
self.gateway = gateway or create_gateway()
self.infrared_catalog = InfraredSlotCatalog(catalog_db_path)
self._gateway_lock = threading.RLock()
self._catalog_sync_lock = threading.RLock()
self._started = False
self.command_timeout = float(_env_text("GSCU_COMMAND_TIMEOUT", "15"))
self.openai_base_url = _env_text(
@@ -2389,25 +2403,53 @@ class SelfExtensionService:
return self.infrared_catalog.list_routing_capabilities()
def sync_peripheral_catalog(self, catalog: object) -> dict[str, Any]:
def peripheral_catalog_revision(self) -> str:
"""Return a stable revision of the durable IR execution whitelist."""
catalog = self.list_peripheral_capabilities()
canonical = json.dumps(
sorted(catalog, key=lambda item: str(item.get("id") or "")),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
def sync_peripheral_catalog(
self,
catalog: object,
*,
expected_revision: object = None,
) -> dict[str, Any]:
"""Persist the complete learned capability catalog from the NFC UI."""
if not isinstance(catalog, list) or len(catalog) > MAX_CATALOG_ITEMS:
raise GatewayError(
f"红外能力目录必须是至多 {MAX_CATALOG_ITEMS} 项的数组。"
)
learned = [
item
for item in catalog
if isinstance(item, dict) and item.get("status") == "learned"
]
capabilities = self.infrared_catalog.sync_capabilities(learned)
return {
"success": True,
"status": "ready",
"count": len(capabilities),
"catalog": capabilities,
}
with self._catalog_sync_lock:
current_revision = self.peripheral_catalog_revision()
clean_expected_revision = str(expected_revision or "").strip()
if (
clean_expected_revision
and clean_expected_revision != current_revision
):
raise PeripheralCatalogConflictError(
"红外能力目录已被其他会话更新,请刷新后重试。"
)
learned = [
item
for item in catalog
if isinstance(item, dict) and item.get("status") == "learned"
]
capabilities = self.infrared_catalog.sync_capabilities(learned)
return {
"success": True,
"status": "ready",
"count": len(capabilities),
"catalog": capabilities,
"catalog_revision": self.peripheral_catalog_revision(),
}
def save_infrared_slot(self, slot: Any, payload: dict[str, Any]) -> dict[str, Any]:
record = self.infrared_catalog.upsert(
@@ -2555,6 +2597,13 @@ class SelfExtensionService:
) -> dict[str, Any]:
"""Execute a persistently NFC-bound match through the main request API."""
if (
capability.get("parse_source") == "scene_composer_capability_ref"
and execution_mode != "dry_run"
):
raise ExtensionIntegrationError(
"场景创建阶段的红外能力引用只允许 dry-run 校验。"
)
if (
capability.get("enabled", True) is False
or not _normalized_card_uid(capability.get("card_uid"))
@@ -2707,7 +2756,7 @@ class SelfExtensionService:
"execution_mode": execution_mode,
"control_transport": control_transport,
"decision_summary": [
f"已通过持久 NFC 绑定 {capability.get('card_uid')} 匹配外设能力。",
"已通过持久 NFC 绑定校验并匹配外设能力。",
f"命中已学习动作:{capability.get('device_name')} · {capability.get('action')}(槽位 {slot})。",
],
"trace_events": [
@@ -2873,6 +2922,14 @@ class SelfExtensionService:
"operations": function_operations,
},
}
if execution_mode != "dry_run" and any(
isinstance(item, Mapping)
and item.get("parse_source") == "scene_composer_capability_ref"
for item in catalog
):
raise ExtensionIntegrationError(
"场景创建阶段的红外能力引用只允许 dry-run 校验。"
)
command_plan = {
"apiVersion": "cognitive-agent.io/v1alpha1",
"kind": "CommandPlan",
@@ -3150,7 +3207,7 @@ class SelfExtensionService:
else "GPT 已生成外设顺序计划。"
)
),
f"计划仅引用持久 NFC 绑定 {first_capability.get('card_uid')} 的已学习能力。",
"计划仅引用已通过持久 NFC 绑定校验的已学习能力。",
*(
[
"当前绑定未学习专用闪烁能力;为避免灯具停在关闭态及学习器持续蜂鸣,"