feat(n7web): enhance control workflows and status feedback

This commit is contained in:
Miayui
2026-09-16 12:52:07 +08:00
parent 84a3cd73bb
commit 0e4328f548
45 changed files with 7317 additions and 1001 deletions
+123 -37
View File
@@ -12,6 +12,7 @@ import ssl
import threading
import time
import uuid
from collections.abc import Mapping
from contextlib import closing
from datetime import UTC, datetime
from pathlib import Path
@@ -955,7 +956,7 @@ def _peripheral_execution_identity_authorized(
capability: dict[str, Any],
) -> bool:
# Saved modes execute an immutable, previously validated command flow and
# are rebound to the same live NFC/device/slot tuple before reaching here.
# are rebound to the same persisted NFC/device/slot tuple before reaching here.
if capability.get("parse_source") == "saved_mode_command_flow":
return True
return bool(_device_match_score(_compact_match_text(utterance), capability))
@@ -1089,7 +1090,7 @@ def _deterministic_peripheral_steps(
utterance: str,
catalog: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Build an ordered live-peripheral plan when every phrase is unambiguous."""
"""Build an ordered bound-peripheral plan when every phrase is unambiguous."""
compact_utterance = _compact_match_text(utterance)
learned = [
@@ -1229,11 +1230,13 @@ def resolve_active_peripheral_command(
catalog: list[dict[str, Any]],
nfc_snapshot: dict[str, Any],
) -> dict[str, Any] | None:
"""Resolve an exact learned action for the NFC card that is present now.
"""Resolve an exact learned action from the durable NFC-bound catalog.
An exact peripheral name is required so generic vehicle phrases such as
``打开氛围灯`` keep using the native vehicle domain. Action synonyms are
accepted only after the peripheral identity and live card binding match.
accepted only after the peripheral identity and persisted binding match.
``nfc_snapshot`` is retained in the public signature for compatibility;
card presence is intentionally not an execution prerequisite.
"""
return _resolve_bound_peripheral_command(
@@ -1251,20 +1254,17 @@ def _resolve_bound_peripheral_command(
*,
require_enabled: bool,
) -> dict[str, Any] | None:
"""Resolve a card-bound command, optionally including disabled entries.
"""Resolve a persistently card-bound command, including disabled entries.
Disabled entries are inspected only so the partitioner can report that a
requested action was filtered by the NFC command allowlist. Execution
requested action was filtered by the persisted command allowlist. Execution
continues to use :func:`resolve_active_peripheral_command`, which always
requires ``enabled`` to be true.
"""
del nfc_snapshot # Presence is display state, not persistent authorization.
compact_utterance = _compact_match_text(utterance)
card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None
active_uid = _normalized_card_uid(
card.get("uid") if isinstance(card, dict) else None
)
if not compact_utterance or not active_uid:
if not compact_utterance:
return None
matches: list[tuple[int, int, dict[str, Any]]] = []
@@ -1278,7 +1278,8 @@ def _resolve_bound_peripheral_command(
)
):
continue
if _normalized_card_uid(raw_item.get("card_uid")) != active_uid:
bound_uid = _normalized_card_uid(raw_item.get("card_uid"))
if not bound_uid:
continue
device_score = _device_match_score(compact_utterance, raw_item)
if not device_score:
@@ -1308,7 +1309,7 @@ def _resolve_bound_peripheral_command(
resolved = dict(matches[0][2])
resolved["slot"] = validate_slot(resolved.get("slot"))
resolved["card_uid"] = active_uid
resolved["card_uid"] = _normalized_card_uid(resolved.get("card_uid"))
return resolved
@@ -1326,17 +1327,13 @@ def partition_active_peripheral_intent(
"""Claim only explicit learned-peripheral clauses and preserve CAN text.
The function is deliberately conservative: a clause must contain both a
live NFC-bound device identity and one learned action. When no peripheral
persistently NFC-bound device identity and one learned action. When no peripheral
clause is claimed, ``residual_utterance`` is byte-for-byte the original
trimmed input so the existing CAN parser observes exactly its old input.
"""
original = str(utterance or "").strip()
card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None
active_uid = _normalized_card_uid(
card.get("uid") if isinstance(card, dict) else None
)
if not original or not active_uid:
if not original:
return {
"peripheral_actions": [],
"residual_utterance": original,
@@ -1350,7 +1347,7 @@ def partition_active_peripheral_intent(
for item in catalog[:MAX_CATALOG_ITEMS]
if isinstance(item, dict)
and item.get("status") == "learned"
and _normalized_card_uid(item.get("card_uid")) == active_uid
and bool(_normalized_card_uid(item.get("card_uid")))
]
active_catalog = [
item
@@ -1541,14 +1538,11 @@ def active_peripheral_plan_catalog(
catalog: list[dict[str, Any]],
nfc_snapshot: dict[str, Any],
) -> list[dict[str, Any]]:
"""Return the live-card catalog when a phrase needs an ordered plan."""
"""Return the durable bound catalog when a phrase needs an ordered plan."""
del nfc_snapshot # Presence is display state, not persistent authorization.
compact_utterance = _compact_match_text(utterance)
card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None
active_uid = _normalized_card_uid(
card.get("uid") if isinstance(card, dict) else None
)
if not compact_utterance or not active_uid:
if not compact_utterance:
return []
has_timing = bool(
re.search(r"\d+(?:\.\d+)?(?:ms|s|毫秒|秒|分钟|分)", compact_utterance)
@@ -1568,13 +1562,14 @@ def active_peripheral_plan_catalog(
or raw_item.get("enabled", True) is False
):
continue
if _normalized_card_uid(raw_item.get("card_uid")) != active_uid:
bound_uid = _normalized_card_uid(raw_item.get("card_uid"))
if not bound_uid:
continue
if not _device_match_score(compact_utterance, raw_item):
continue
item = dict(raw_item)
item["slot"] = validate_slot(item.get("slot"))
item["card_uid"] = active_uid
item["card_uid"] = bound_uid
active.append(item)
if _has_unshadowed_vehicle_device_phrase(compact_utterance, active):
return []
@@ -2378,6 +2373,61 @@ class SelfExtensionService:
result["learning_capture_confirmed"] = False
return result
def execute_bound_infrared_action(
self,
action: Mapping[str, Any],
) -> dict[str, Any]:
"""Re-authorize and emit one car-originated ``extension/ctl`` action."""
if (
not isinstance(action, Mapping)
or action.get("type") != "infrared_emit"
or action.get("domain") != "self_extension"
or action.get("capability") != "infrared.emit"
or action.get("operation") != "emit"
):
raise ExtensionIntegrationError("车端外设控制动作格式无效。")
device_id = str(action.get("target_scope") or "").strip()
parameters = action.get("parameters")
if not device_id or not isinstance(parameters, Mapping):
raise ExtensionIntegrationError("车端外设控制动作缺少设备或参数。")
slot = validate_slot(parameters.get("slot"))
action_name = str(parameters.get("action") or "").strip()
if not action_name:
raise ExtensionIntegrationError("车端外设控制动作名称不能为空。")
capability = next(
(
item
for item in self.list_peripheral_capabilities()
if isinstance(item, dict)
and item.get("status") == "learned"
and item.get("enabled") is True
and bool(_normalized_card_uid(item.get("card_uid")))
and str(item.get("device_id") or "").strip() == device_id
and item.get("slot") == slot
and str(item.get("action") or "").strip() == action_name
),
None,
)
if capability is None:
raise ExtensionIntegrationError(
"车端外设控制动作未通过本机持久能力鉴权:"
f"{device_id} / {action_name} / IR {slot}。"
)
result = self.execute_gateway("emit", {"slot": slot})
return {
**result,
"authorization": {
"source": "local_persistent_extension_allowlist",
"capability_id": capability.get("id"),
"device_id": device_id,
"action": action_name,
"slot": slot,
},
}
def execute_peripheral_request(
self,
utterance: str,
@@ -2389,7 +2439,7 @@ class SelfExtensionService:
provider_requested: str = "auto",
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Execute a live-NFC peripheral match through the main request API."""
"""Execute a persistently NFC-bound match through the main request API."""
if not _peripheral_execution_identity_authorized(utterance, capability):
raise ExtensionIntegrationError(
@@ -2536,14 +2586,14 @@ class SelfExtensionService:
"execution_mode": execution_mode,
"control_transport": control_transport,
"decision_summary": [
f"检测到在场 NFC {capability.get('card_uid')},已优先匹配绑定外设。",
f"已通过持久 NFC 绑定 {capability.get('card_uid')} 匹配外设能力。",
f"命中已学习动作:{capability.get('device_name')} · {capability.get('action')}(槽位 {slot})。",
],
"trace_events": [
{
"stage": "nfc_peripheral_route",
"status": "succeeded",
"summary": "在场 NFC、外设名称与已学习动作均匹配",
"summary": "持久 NFC 绑定、外设名称与已学习动作均匹配",
"duration_ms": 0,
}
],
@@ -2592,13 +2642,13 @@ class SelfExtensionService:
provider_requested: str = "auto",
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Execute a validated ordered peripheral/delay plan for the live NFC."""
"""Execute a validated ordered plan from durable NFC-bound abilities."""
steps = plan_result.get("steps", [])
if not isinstance(steps, list) or not steps:
raise ExtensionIntegrationError("GPT 没有生成可执行的外设动作。")
if any(step.get("kind") == "vehicle" for step in steps if isinstance(step, dict)):
raise ExtensionIntegrationError("当前主编排只允许执行在场 NFC 外设计划。")
raise ExtensionIntegrationError("当前主编排只允许执行持久绑定的 NFC 外设计划。")
catalog_by_id = {
str(item.get("id")): item
for item in catalog
@@ -2639,9 +2689,14 @@ class SelfExtensionService:
raise ExtensionIntegrationError("GPT 计划包含未知步骤类型。")
capability_id = str(step.get("capability_id") or "")
capability = catalog_by_id.get(capability_id)
if capability is None or capability.get("status") != "learned":
if (
capability is None
or capability.get("status") != "learned"
or capability.get("enabled", True) is False
or not _normalized_card_uid(capability.get("card_uid"))
):
raise ExtensionIntegrationError(
"GPT 计划引用了不在当前 NFC 目录中的能力。"
"GPT 计划引用了未学习、未启用或缺少持久 NFC 绑定的能力。"
)
if not _peripheral_execution_identity_authorized(utterance, capability):
raise ExtensionIntegrationError(
@@ -2880,6 +2935,37 @@ class SelfExtensionService:
}
operations.append(operation)
if interrupted:
for remaining_index in range(index + 1, len(steps)):
remaining_step = steps[remaining_index]
if remaining_step.get("kind") != "peripheral":
continue
(
_remaining_capability_id,
_remaining_capability,
remaining_slot,
remaining_operation_ref,
) = compiled_peripheral_steps[remaining_index]
operations.append(
{
"ref": remaining_operation_ref,
"domain": "self_extension",
"status": "aborted",
"message": "前序红外指令失败,未执行",
"telemetry": {
"transport": transport,
"slot": remaining_slot,
"delivery_status": "not_submitted",
"transport_submitted": False,
"gateway_acknowledged": False,
"physical_effect_confirmed": False,
"command": build_gateway_command(
"emit", {"slot": remaining_slot}
),
"effect_mode": "one_shot",
"abort_reason": "prior_operation_failed",
},
}
)
break
if (
hardware_request
@@ -2943,10 +3029,10 @@ class SelfExtensionService:
else "GPT 已生成外设顺序计划。"
)
),
f"计划仅引用当前在场 NFC {first_capability.get('card_uid')} 的已学习能力。",
f"计划仅引用持久 NFC 绑定 {first_capability.get('card_uid')} 的已学习能力。",
*(
[
"当前 NFC 未学习专用闪烁能力;为避免灯具停在关闭态及学习器持续蜂鸣,"
"当前绑定未学习专用闪烁能力;为避免灯具停在关闭态及学习器持续蜂鸣,"
"闪烁已安全降级为定色保持。"
]
if steady_blink_fallbacks