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:
@@ -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 绑定校验的已学习能力。",
|
||||
*(
|
||||
[
|
||||
"当前绑定未学习专用闪烁能力;为避免灯具停在关闭态及学习器持续蜂鸣,"
|
||||
|
||||
Reference in New Issue
Block a user