feat(web): add peripheral card quick controls

This commit is contained in:
Miayui
2026-09-21 16:01:42 +08:00
parent ad0c1f4ed1
commit ba5dfcd6da
14 changed files with 530 additions and 19 deletions
+1 -1
View File
@@ -44,7 +44,7 @@
<script type="module" src="/gesture-control.js?v=android-pad-9"></script> <script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script> <script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=local-first-79-cinema-status" defer></script> <script src="/console-v2.js?v=local-first-79-cinema-status" defer></script>
<script src="/self-extension.js?v=nfc-session-58" defer></script> <script src="/self-extension.js?v=peripheral-cards-59" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-39-video-media" defer></script> <script src="/assistant-chat.js?v=codex-assistant-39-video-media" defer></script>
<script src="/energy-monitor.js?v=energy-monitor-5-performance" defer></script> <script src="/energy-monitor.js?v=energy-monitor-5-performance" defer></script>
<script src="/firmware-workbench-entry.js?v=3" defer></script> <script src="/firmware-workbench-entry.js?v=3" defer></script>
+171
View File
@@ -2698,6 +2698,146 @@ form > .v2-extension-primary {
font-size: 7px; font-size: 7px;
} }
.v2-extension-synced-device {
overflow: hidden;
padding: 0;
border: 1px solid rgba(107, 214, 203, 0.12);
transition: border-color 160ms ease, background 160ms ease;
}
.v2-extension-synced-device[data-expanded="true"] {
border-color: rgba(107, 214, 203, 0.3);
background: rgba(87, 191, 180, 0.075);
}
.v2-extension-synced-device > header {
display: flex;
align-items: center;
gap: 4px;
padding: 7px 8px;
}
.v2-extension-device-toggle {
min-width: 0;
flex: 1;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto 14px;
align-items: center;
gap: 8px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
}
.v2-extension-device-toggle > span {
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: #76cfc6;
background: rgba(87, 191, 180, 0.1);
border-radius: 8px;
}
.v2-extension-device-toggle > span svg,
.v2-extension-device-toggle > svg {
width: 14px;
height: 14px;
}
.v2-extension-device-toggle > div {
min-width: 0;
display: grid;
}
.v2-extension-device-toggle > div strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-extension-device-toggle > b {
white-space: nowrap;
}
.v2-extension-device-toggle > svg {
color: #8da7a5;
transition: transform 160ms ease;
}
.v2-extension-synced-device[data-expanded="true"] .v2-extension-device-toggle > svg {
transform: rotate(180deg);
}
.v2-extension-device-hide {
width: 27px;
height: 27px;
flex: none;
display: grid;
place-items: center;
padding: 0;
color: #8da7a5;
background: transparent;
border: 0;
border-radius: 7px;
cursor: pointer;
}
.v2-extension-device-hide:is(:hover, :focus-visible) {
color: #eaa89c;
background: rgba(216, 99, 82, 0.12);
}
.v2-extension-device-hide svg {
width: 13px;
height: 13px;
}
.v2-extension-synced-device > .v2-extension-quick-controls {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin: 0;
padding: 0 8px 8px 44px;
border-top: 1px solid rgba(107, 214, 203, 0.09);
}
.v2-extension-quick-controls > button {
min-height: 25px;
padding: 4px 8px;
color: #b9d8d4;
background: rgba(95, 190, 180, 0.1);
border: 1px solid rgba(107, 214, 203, 0.13);
border-radius: 999px;
font-size: 7px;
font-weight: 800;
cursor: pointer;
}
.v2-extension-quick-controls > button:is(:hover, :focus-visible) {
color: #effaf8;
background: rgba(95, 190, 180, 0.2);
border-color: rgba(107, 214, 203, 0.3);
}
.v2-extension-quick-controls > button:disabled {
opacity: 0.55;
cursor: wait;
}
.v2-extension-quick-controls > button[data-state="success"] {
color: #bdf2d5;
background: rgba(61, 173, 112, 0.16);
}
.v2-extension-quick-controls > button[data-state="error"] {
color: #f0b4aa;
background: rgba(216, 99, 82, 0.14);
}
.v2-extension-sync-mount .v2-extension-empty { .v2-extension-sync-mount .v2-extension-empty {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3547,6 +3687,37 @@ form > .v2-extension-primary {
} }
} }
.console-v2 #v2ExtensionPanel .v2-extension-device-toggle > span {
color: var(--primary);
background: var(--accent);
}
.console-v2 #v2ExtensionPanel .v2-extension-device-toggle > svg,
.console-v2 #v2ExtensionPanel .v2-extension-device-hide {
color: var(--muted-foreground);
}
.console-v2 #v2ExtensionPanel .v2-extension-synced-device[data-expanded="true"] {
background: color-mix(in oklch, var(--accent) 64%, var(--card));
border-color: color-mix(in oklch, var(--primary) 30%, var(--border));
}
.console-v2 #v2ExtensionPanel .v2-extension-synced-device > .v2-extension-quick-controls {
border-top-color: var(--border);
}
.console-v2 #v2ExtensionPanel .v2-extension-quick-controls > button {
color: var(--primary);
background: var(--accent);
border-color: color-mix(in oklch, var(--primary) 18%, var(--border));
}
.console-v2 #v2ExtensionPanel .v2-extension-quick-controls > button:is(:hover, :focus-visible) {
color: var(--primary-foreground);
background: var(--primary);
border-color: var(--primary);
}
/* Final semantic theme layer for extension surfaces and nested dialogs --- */ /* Final semantic theme layer for extension surfaces and nested dialogs --- */
.console-v2 #v2ExtensionPanel .v2-extension-sync-mount, .console-v2 #v2ExtensionPanel .v2-extension-sync-mount,
.console-v2 #v2ExtensionPanel .v2-extension-gateway, .console-v2 #v2ExtensionPanel .v2-extension-gateway,
+207 -10
View File
@@ -5,6 +5,7 @@
const SLOT_CATALOG_MIGRATION_KEY = "cognitive-agent.gscu-slot-catalog-migrated.v1"; const SLOT_CATALOG_MIGRATION_KEY = "cognitive-agent.gscu-slot-catalog-migrated.v1";
const MODE_KEY = "cognitive-agent.extension-mode"; const MODE_KEY = "cognitive-agent.extension-mode";
const CONVERSATION_KEY = "cognitive-agent.conversation-id"; const CONVERSATION_KEY = "cognitive-agent.conversation-id";
const PERIPHERAL_CARD_UI_KEY = "cognitive-agent.extension-peripheral-cards.v1";
const SLOT_MIN = 1; const SLOT_MIN = 1;
const SLOT_MAX = 248; const SLOT_MAX = 248;
const ACTION_PAGE_SIZE = 6; const ACTION_PAGE_SIZE = 6;
@@ -375,10 +376,13 @@
}; };
let extensionToySnapshot = { let extensionToySnapshot = {
revision: -1, revision: -1,
session_id: null,
state: "stopped", state: "stopped",
topic: "extension/toy", topic: "extension/toy",
burst_published_count: 0, burst_published_count: 0,
detected_nfc_ids: [], detected_nfc_ids: [],
detection_count: 0,
active_nfc_id: null,
last_error: null, last_error: null,
}; };
let peripheralCatalogRevision = null; let peripheralCatalogRevision = null;
@@ -410,6 +414,10 @@
let legacyMigrationCompleted = false; let legacyMigrationCompleted = false;
const legacyBridgeAttempts = new Set(); const legacyBridgeAttempts = new Set();
let renderUnifiedExtensionView = null; let renderUnifiedExtensionView = null;
const hiddenPeripheralDevices = new Map();
let expandedPeripheralDeviceId = null;
let peripheralCardSessionId = "";
let runningPeripheralQuickAction = false;
let latestVehicleStateTurn = -1; let latestVehicleStateTurn = -1;
let sharedTwinBand = null; let sharedTwinBand = null;
let sharedTwinHome = null; let sharedTwinHome = null;
@@ -1494,6 +1502,59 @@
return String(value || "").replace(/[^0-9a-f]/gi, "").toUpperCase(); return String(value || "").replace(/[^0-9a-f]/gi, "").toUpperCase();
} }
function persistPeripheralCardUi() {
try {
sessionStorage.setItem(PERIPHERAL_CARD_UI_KEY, JSON.stringify({
sessionId: peripheralCardSessionId,
hidden: [...hiddenPeripheralDevices].map(([deviceId, value]) => ({
deviceId,
cardUid: value.cardUid,
detectionCount: value.detectionCount,
})),
}));
} catch (_error) {
// The in-memory state remains authoritative when storage is unavailable.
}
}
function restorePeripheralCardUi(sessionId) {
hiddenPeripheralDevices.clear();
expandedPeripheralDeviceId = null;
peripheralCardSessionId = String(sessionId || "");
try {
const saved = JSON.parse(sessionStorage.getItem(PERIPHERAL_CARD_UI_KEY) || "null");
if (!saved || saved.sessionId !== peripheralCardSessionId) {
persistPeripheralCardUi();
return;
}
(Array.isArray(saved.hidden) ? saved.hidden : []).forEach((item) => {
const deviceId = String(item?.deviceId || "").trim();
const cardUid = normalizeCardUid(item?.cardUid);
const detectionCount = Number(item?.detectionCount ?? -1);
if (deviceId && cardUid && Number.isInteger(detectionCount)) {
hiddenPeripheralDevices.set(deviceId, { cardUid, detectionCount });
}
});
} catch (_error) {
persistPeripheralCardUi();
}
}
function revealRedetectedPeripheral(value) {
const cardUid = normalizeCardUid(value?.active_nfc_id);
const detectionCount = Number(value?.detection_count ?? -1);
if (!cardUid || !Number.isInteger(detectionCount)) return false;
let changed = false;
hiddenPeripheralDevices.forEach((hidden, deviceId) => {
if (hidden.cardUid === cardUid && hidden.detectionCount !== detectionCount) {
hiddenPeripheralDevices.delete(deviceId);
changed = true;
}
});
if (changed) persistPeripheralCardUi();
return changed;
}
function renderMetrics() { function renderMetrics() {
$("#extensionHardwareCount").textContent = String(state.devices.length); $("#extensionHardwareCount").textContent = String(state.devices.length);
$("#extensionCapabilityCount").textContent = String(learnedCapabilities().length); $("#extensionCapabilityCount").textContent = String(learnedCapabilities().length);
@@ -4007,9 +4068,16 @@
function applyExtensionToySnapshot(value, { announce = true } = {}) { function applyExtensionToySnapshot(value, { announce = true } = {}) {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const revision = Number(value.revision ?? -1); const revision = Number(value.revision ?? -1);
if (revision === extensionToySnapshot.revision) return false; const sessionId = String(value.session_id || "");
const sessionChanged = sessionId !== peripheralCardSessionId;
if (sessionChanged) restorePeripheralCardUi(sessionId);
const cardRevealed = revealRedetectedPeripheral(value);
if (revision === extensionToySnapshot.revision
&& sessionId === String(extensionToySnapshot.session_id || "")
&& !cardRevealed) return false;
extensionToySnapshot = { extensionToySnapshot = {
revision, revision,
session_id: sessionId || null,
state: String(value.state || "stopped"), state: String(value.state || "stopped"),
topic: String(value.topic || "extension/toy"), topic: String(value.topic || "extension/toy"),
burst_published_count: Number(value.burst_published_count || 0), burst_published_count: Number(value.burst_published_count || 0),
@@ -4019,9 +4087,12 @@
detected_nfc_ids: Array.isArray(value.detected_nfc_ids) detected_nfc_ids: Array.isArray(value.detected_nfc_ids)
? value.detected_nfc_ids.map(normalizeCardUid).filter(Boolean) ? value.detected_nfc_ids.map(normalizeCardUid).filter(Boolean)
: [], : [],
detection_count: Number(value.detection_count || 0),
active_nfc_id: normalizeCardUid(value.active_nfc_id) || null,
last_delivery_status: value.last_delivery_status || null, last_delivery_status: value.last_delivery_status || null,
last_error: value.last_error || null, last_error: value.last_error || null,
}; };
if (sessionChanged || cardRevealed) unifiedExtensionDirty = true;
if (announce && extensionToySnapshot.state === "burst_complete") { if (announce && extensionToySnapshot.state === "burst_complete") {
showToast( showToast(
`已向 ${extensionToySnapshot.topic} 发送 ${extensionToySnapshot.burst_published_count} 帧设备能力消息。`, `已向 ${extensionToySnapshot.topic} 发送 ${extensionToySnapshot.burst_published_count} 帧设备能力消息。`,
@@ -4119,8 +4190,107 @@
void refreshNfcStatus(); void refreshNfcStatus();
} }
function hidePeripheralCard(deviceId, cardUid) {
const normalizedDeviceId = String(deviceId || "").trim();
const normalizedCardUid = normalizeCardUid(cardUid);
if (!normalizedDeviceId || !normalizedCardUid) return;
hiddenPeripheralDevices.set(normalizedDeviceId, {
cardUid: normalizedCardUid,
detectionCount: Number(extensionToySnapshot.detection_count || 0),
});
if (expandedPeripheralDeviceId === normalizedDeviceId) {
expandedPeripheralDeviceId = null;
}
persistPeripheralCardUi();
unifiedExtensionDirty = true;
renderUnifiedExtensionView?.({ force: true });
showToast("外设卡片已隐藏;再次感应对应 NFC 后将重新显示。", "success");
}
function togglePeripheralCard(deviceId) {
const normalizedDeviceId = String(deviceId || "").trim();
if (!normalizedDeviceId) return;
expandedPeripheralDeviceId = expandedPeripheralDeviceId === normalizedDeviceId
? null
: normalizedDeviceId;
unifiedExtensionDirty = true;
renderUnifiedExtensionView?.({ force: true });
}
async function runPeripheralQuickAction(button) {
const capabilityId = String(button?.dataset.runPeripheralQuickAction || "").trim();
if (!capabilityId || button.disabled) return;
if (runningPeripheralQuickAction) {
showToast("上一条外设快捷指令仍在处理,请稍候。", "error");
return;
}
const capability = durablePeripheralCatalog.find(
(item) => String(item.id || "") === capabilityId,
);
const deviceName = String(
capability?.device_name || capability?.device_id || "外设",
);
const actionName = String(capability?.action || capability?.name || "动作");
if ((button.dataset.risk || capability?.risk) === "high" && !window.confirm(
`“${deviceName} · ${actionName}”可能让外设移动,请确认周围安全。`,
)) return;
const previousText = button.textContent;
runningPeripheralQuickAction = true;
button.disabled = true;
button.dataset.state = "running";
button.textContent = "下发中…";
try {
await api("peripherals/execute", {
method: "POST",
body: JSON.stringify({
capability_id: capabilityId,
conversation_id: sharedConversationId(),
}),
});
button.dataset.state = "success";
button.textContent = "已下发";
showToast(`${deviceName} · ${actionName} 已下发。`, "success");
window.setTimeout(() => {
if (!button.isConnected) return;
button.dataset.state = "";
button.textContent = previousText;
button.disabled = false;
}, 1200);
} catch (error) {
button.dataset.state = "error";
button.textContent = "下发失败";
showToast(error.message, "error");
window.setTimeout(() => {
if (!button.isConnected) return;
button.dataset.state = "";
button.textContent = previousText;
button.disabled = false;
}, 1600);
} finally {
runningPeripheralQuickAction = false;
}
}
function bindUnifiedNfcEvents(mount) { function bindUnifiedNfcEvents(mount) {
mount.addEventListener("click", (event) => { mount.addEventListener("click", (event) => {
const quickAction = event.target.closest("[data-run-peripheral-quick-action]");
if (quickAction) {
void runPeripheralQuickAction(quickAction);
return;
}
const hideCard = event.target.closest("[data-hide-peripheral-card]");
if (hideCard) {
hidePeripheralCard(
hideCard.dataset.hidePeripheralCard,
hideCard.dataset.cardUid,
);
return;
}
const toggleCard = event.target.closest("[data-toggle-peripheral-card]");
if (toggleCard) {
togglePeripheralCard(toggleCard.dataset.togglePeripheralCard);
return;
}
if (event.target.closest("[data-open-pet-settings]")) openPetSettingsDialog(); if (event.target.closest("[data-open-pet-settings]")) openPetSettingsDialog();
else if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog(); else if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog();
else if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog(); else if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
@@ -4237,6 +4407,11 @@
card: nfcSnapshot.card, card: nfcSnapshot.card,
}, },
detectedExtensionNfcIds: extensionToySnapshot.detected_nfc_ids, detectedExtensionNfcIds: extensionToySnapshot.detected_nfc_ids,
peripheralCardUi: {
sessionId: peripheralCardSessionId,
hiddenDeviceIds: [...hiddenPeripheralDevices.keys()].sort(),
expandedDeviceId: expandedPeripheralDeviceId,
},
pets: petCardSnapshot, pets: petCardSnapshot,
localDevices: state.devices.map((device) => ({ localDevices: state.devices.map((device) => ({
id: device.id, id: device.id,
@@ -4335,14 +4510,23 @@
actions: [], actions: [],
}); });
const actionName = item.action || item.name || "已学习能力"; const actionName = item.action || item.name || "已学习能力";
grouped.get(deviceId).actions.push( grouped.get(deviceId).actions.push({
item.enabled === false ? `${actionName}(已停用)` : actionName, id: String(item.id || ""),
); name: actionName,
enabled: item.enabled !== false,
risk: item.risk || "low",
slot: item.slot,
});
}); });
const detectedNfcIds = new Set(extensionToySnapshot.detected_nfc_ids); const detectedNfcIds = new Set(extensionToySnapshot.detected_nfc_ids);
const devices = [...grouped.values()].filter( const devices = [...grouped.values()].filter(
(device) => detectedNfcIds.has(normalizeCardUid(device.cardUid)), (device) => detectedNfcIds.has(normalizeCardUid(device.cardUid))
&& !hiddenPeripheralDevices.has(device.id),
); );
if (expandedPeripheralDeviceId
&& !devices.some((device) => device.id === expandedPeripheralDeviceId)) {
expandedPeripheralDeviceId = null;
}
const lastAction = snapshot?.extension?.last_action; const lastAction = snapshot?.extension?.last_action;
const lastResult = snapshot?.extension?.result; const lastResult = snapshot?.extension?.result;
const nfcCapabilities = nfcDevice const nfcCapabilities = nfcDevice
@@ -4419,11 +4603,24 @@
</div> </div>
<div class="v2-extension-device-list"> <div class="v2-extension-device-list">
${nfcPet ? renderDetectedPetCard(nfcPet) : ""} ${nfcPet ? renderDetectedPetCard(nfcPet) : ""}
${devices.length ? devices.map((device) => ` ${devices.length ? devices.map((device) => {
<article class="v2-extension-synced-device"> const expanded = expandedPeripheralDeviceId === device.id;
<header><span><i data-lucide="${device.cardUid ? "badge-check" : "cpu"}"></i></span><div><strong title="${escapeHtml(device.type || device.name)}">${escapeHtml(device.name)}</strong></div><b>${device.actions.length} 项能力</b></header> return `
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div> <article class="v2-extension-synced-device" data-expanded="${String(expanded)}" data-extension-synced-device="${escapeHtml(device.id)}">
</article>`).join("") : nfcPet ? "" : ` <header>
<button class="v2-extension-device-toggle" type="button" data-toggle-peripheral-card="${escapeHtml(device.id)}" aria-expanded="${String(expanded)}">
<span><i data-lucide="${device.cardUid ? "badge-check" : "cpu"}"></i></span>
<div><strong title="${escapeHtml(device.type || device.name)}">${escapeHtml(device.name)}</strong></div>
<b>${device.actions.length} 项能力</b>
<i data-lucide="chevron-down"></i>
</button>
<button class="v2-extension-device-hide" type="button" data-hide-peripheral-card="${escapeHtml(device.id)}" data-card-uid="${escapeHtml(device.cardUid)}" title="隐藏${escapeHtml(device.name)}" aria-label="隐藏${escapeHtml(device.name)}"><i data-lucide="eye-off"></i></button>
</header>
${expanded ? `<div class="v2-extension-quick-controls" aria-label="${escapeHtml(device.name)}快捷控制">
${device.actions.map((action) => `<button type="button" data-run-peripheral-quick-action="${escapeHtml(action.id)}" data-risk="${escapeHtml(action.risk)}" ${action.enabled && action.id ? "" : "disabled"}>${escapeHtml(action.name)}${action.enabled ? "" : "(已停用)"}</button>`).join("")}
</div>` : ""}
</article>`;
}).join("") : nfcPet ? "" : `
<div class="v2-extension-empty"> <div class="v2-extension-empty">
<i data-lucide="scan-line"></i><div><strong>等待 NFC 外设靠近</strong><span>检测到已绑定的 NFC 后,外设将在本次运行期间持续显示</span></div> <i data-lucide="scan-line"></i><div><strong>等待 NFC 外设靠近</strong><span>检测到已绑定的 NFC 后,外设将在本次运行期间持续显示</span></div>
</div>`} </div>`}
+2 -2
View File
@@ -11,8 +11,8 @@ const APP_SHELL = [
"/pwa.js?v=android-pad-3", "/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-69", "/console-v2.css?v=delivery-state-69",
"/console-v2.js?v=local-first-79-cinema-status", "/console-v2.js?v=local-first-79-cinema-status",
"/self-extension.css?v=pet-settings-54-dialog-size", "/self-extension.css?v=peripheral-cards-55",
"/self-extension.js?v=nfc-session-58", "/self-extension.js?v=peripheral-cards-59",
"/speech-input.css?v=sensevoice-7", "/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13", "/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5", "/speech-audio-worklet.js?v=sensevoice-5",
@@ -6,6 +6,7 @@ import json
import re import re
import threading import threading
import time import time
import uuid
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from copy import deepcopy from copy import deepcopy
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -157,6 +158,7 @@ class NfcExtensionToyPublisher:
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._revision = 0 self._revision = 0
self._status: dict[str, Any] = { self._status: dict[str, Any] = {
"session_id": None,
"state": "stopped", "state": "stopped",
"topic": EXTENSION_TOY_MQTT_TOPIC, "topic": EXTENSION_TOY_MQTT_TOPIC,
"qos": 1, "qos": 1,
@@ -168,6 +170,7 @@ class NfcExtensionToyPublisher:
"active_extension_names": [], "active_extension_names": [],
"active_target_scopes": [], "active_target_scopes": [],
"detected_nfc_ids": [], "detected_nfc_ids": [],
"detection_count": 0,
"message_count": 0, "message_count": 0,
"burst_frame_attempt_count": 0, "burst_frame_attempt_count": 0,
"burst_publish_attempt_count": 0, "burst_publish_attempt_count": 0,
@@ -188,12 +191,14 @@ class NfcExtensionToyPublisher:
return return
self._stop.clear() self._stop.clear()
self._set_status( self._set_status(
session_id=uuid.uuid4().hex,
state="waiting_for_nfc", state="waiting_for_nfc",
active_nfc_id=None, active_nfc_id=None,
active_catalog_revision=None, active_catalog_revision=None,
active_extension_names=[], active_extension_names=[],
active_target_scopes=[], active_target_scopes=[],
detected_nfc_ids=[], detected_nfc_ids=[],
detection_count=0,
message_count=0, message_count=0,
last_error=None, last_error=None,
) )
@@ -211,12 +216,14 @@ class NfcExtensionToyPublisher:
thread.join(timeout=2.0) thread.join(timeout=2.0)
self._thread = None self._thread = None
self._set_status( self._set_status(
session_id=None,
state="stopped", state="stopped",
active_nfc_id=None, active_nfc_id=None,
active_catalog_revision=None, active_catalog_revision=None,
active_extension_names=[], active_extension_names=[],
active_target_scopes=[], active_target_scopes=[],
detected_nfc_ids=[], detected_nfc_ids=[],
detection_count=0,
message_count=0, message_count=0,
) )
@@ -339,6 +346,7 @@ class NfcExtensionToyPublisher:
def _run(self) -> None: def _run(self) -> None:
latched_uid = "" latched_uid = ""
present_uid = ""
latched_catalog_revision = "" latched_catalog_revision = ""
latched_message_signature: str | None = None latched_message_signature: str | None = None
while not self._stop.is_set(): while not self._stop.is_set():
@@ -349,6 +357,16 @@ class NfcExtensionToyPublisher:
self._stop.wait(self.poll_interval_s) self._stop.wait(self.poll_interval_s)
continue continue
if card_uid != present_uid:
present_uid = card_uid
if card_uid:
with self._lock:
detection_count = int(self._status["detection_count"]) + 1
self._set_status(
active_nfc_id=card_uid,
detection_count=detection_count,
)
if card_uid: if card_uid:
with self._lock: with self._lock:
detected_nfc_ids = list(self._status["detected_nfc_ids"]) detected_nfc_ids = list(self._status["detected_nfc_ids"])
@@ -2976,6 +2976,48 @@ class SelfExtensionService:
}, },
} }
def execute_quick_peripheral_capability(
self,
capability_id: object,
*,
conversation_id: str | None = None,
) -> dict[str, Any]:
"""Execute one durable NFC-authorized capability selected by the UI."""
normalized_id = str(capability_id or "").strip()
if not normalized_id or len(normalized_id) > 128:
raise ValueError("外设快捷控制缺少有效的能力 ID。")
capability = next(
(
dict(item)
for item in self.list_peripheral_capabilities()
if isinstance(item, Mapping)
and str(item.get("id") or "").strip() == normalized_id
and item.get("status") == "learned"
and item.get("enabled") is True
and bool(_normalized_card_uid(item.get("card_uid")))
),
None,
)
if capability is None:
raise ExtensionIntegrationError(
"外设快捷控制未通过当前持久 NFC 能力目录校验。"
)
device_name = str(
capability.get("device_name")
or capability.get("device_id")
or "外设"
).strip()
action_name = str(capability.get("action") or "动作").strip()
result = self.execute_peripheral_request(
f"{device_name} {action_name}",
capability,
conversation_id=conversation_id,
execution_mode="hardware",
provider_requested="web_console_quick_control",
)
return {"success": True, **result}
def execute_peripheral_plan_request( def execute_peripheral_plan_request(
self, self,
utterance: str, utterance: str,
+15
View File
@@ -4554,6 +4554,21 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) )
self._json({"success": True, **sync_result}) self._json({"success": True, **sync_result})
return return
if path == "/api/extension/peripherals/execute":
result = self.extension.execute_quick_peripheral_capability(
payload.get("capability_id"),
conversation_id=str(payload.get("conversation_id") or "").strip()
or None,
)
self.vehicle_display_sync.update_extension(
action={
"type": "peripheral_quick_control",
"command": result.get("utterance"),
},
result=result,
)
self._json(result)
return
if path == "/api/extension/assistant/plan": if path == "/api/extension/assistant/plan":
self.vehicle_display_sync.update_extension( self.vehicle_display_sync.update_extension(
catalog=payload.get("peripherals", payload.get("catalog", [])), catalog=payload.get("peripherals", payload.get("catalog", [])),
+3 -3
View File
@@ -1024,11 +1024,11 @@ operations:
) as response: ) as response:
speech_worklet = response.read().decode("utf-8") speech_worklet = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/self-extension.js?v=nfc-session-58", timeout=3 f"{self.base_url}/self-extension.js?v=peripheral-cards-59", timeout=3
) as response: ) as response:
extension_script = response.read().decode("utf-8") extension_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/self-extension.css?v=pet-settings-54-dialog-size", timeout=3 f"{self.base_url}/self-extension.css?v=peripheral-cards-55", timeout=3
) as response: ) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n") extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open( with self.opener.open(
@@ -1105,7 +1105,7 @@ operations:
self.assertIn('id="v2BootScreen"', template) self.assertIn('id="v2BootScreen"', template)
self.assertIn('正在加载车机控制与实时状态', template) self.assertIn('正在加载车机控制与实时状态', template)
self.assertIn( self.assertIn(
f'src="{runtime_url("self-extension.js", "/self-extension.js?v=nfc-session-58")}"', f'src="{runtime_url("self-extension.js", "/self-extension.js?v=peripheral-cards-59")}"',
html, html,
) )
self.assertIn('import "../self-extension.css";', vite_entry) self.assertIn('import "../self-extension.css";', vite_entry)
@@ -131,6 +131,9 @@ def test_each_detection_publishes_three_frames_per_device_and_then_stops() -> No
for _at, publication in publications for _at, publication in publications
) == {"toy-fan-001": 3, "toy-light-001": 3} ) == {"toy-fan-001": 3, "toy-light-001": 3}
status = service.snapshot() status = service.snapshot()
session_id = status["session_id"]
assert isinstance(session_id, str) and session_id
assert status["detection_count"] == 1
assert status["state"] == "burst_complete" assert status["state"] == "burst_complete"
assert status["burst_frame_attempt_count"] == 3 assert status["burst_frame_attempt_count"] == 3
assert status["burst_publish_attempt_count"] == 6 assert status["burst_publish_attempt_count"] == 6
@@ -147,6 +150,8 @@ def test_each_detection_publishes_three_frames_per_device_and_then_stops() -> No
nfc["card"] = {"action": "present", "uid": "E113B203"} nfc["card"] = {"action": "present", "uid": "E113B203"}
assert wait_until(lambda: len(publications) == 12) assert wait_until(lambda: len(publications) == 12)
assert service.snapshot()["state"] == "burst_complete" assert service.snapshot()["state"] == "burst_complete"
assert service.snapshot()["session_id"] == session_id
assert service.snapshot()["detection_count"] == 2
finally: finally:
service.close() service.close()
@@ -172,6 +177,8 @@ def test_detected_devices_remain_latched_until_publisher_stops() -> None:
service.close() service.close()
assert service.snapshot()["detected_nfc_ids"] == [] assert service.snapshot()["detected_nfc_ids"] == []
assert service.snapshot()["session_id"] is None
assert service.snapshot()["detection_count"] == 0
def test_no_matching_ability_publishes_nothing() -> None: def test_no_matching_ability_publishes_nothing() -> None:
@@ -94,7 +94,7 @@ def test_performance_assets_have_a_new_cache_generation() -> None:
for asset in ( for asset in (
"/app.js?v=dual-seat-147-video-media", "/app.js?v=dual-seat-147-video-media",
"/console-v2.js?v=local-first-79-cinema-status", "/console-v2.js?v=local-first-79-cinema-status",
"/self-extension.js?v=nfc-session-58", "/self-extension.js?v=peripheral-cards-59",
"/energy-monitor.js?v=energy-monitor-5-performance", "/energy-monitor.js?v=energy-monitor-5-performance",
): ):
assert asset in html assert asset in html
+1 -1
View File
@@ -47,7 +47,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
for source_asset, cached_asset in ( for source_asset, cached_asset in (
('import "../styles.css"', "/styles.css?v=dual-seat-24-dialog-size"), ('import "../styles.css"', "/styles.css?v=dual-seat-24-dialog-size"),
('import "../console-v2.css"', "/console-v2.css?v=delivery-state-69"), ('import "../console-v2.css"', "/console-v2.css?v=delivery-state-69"),
('import "../self-extension.css"', "/self-extension.css?v=pet-settings-54-dialog-size"), ('import "../self-extension.css"', "/self-extension.css?v=peripheral-cards-55"),
('import "../speech-input.css"', "/speech-input.css?v=sensevoice-7"), ('import "../speech-input.css"', "/speech-input.css?v=sensevoice-7"),
('import "../codepen-glass-theme.css"', "/codepen-glass-theme.css?v=creative-cloud-6-performance"), ('import "../codepen-glass-theme.css"', "/codepen-glass-theme.css?v=creative-cloud-6-performance"),
): ):
+13 -1
View File
@@ -67,13 +67,23 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
assert 'signalSource: "backend_catalog"' in script assert 'signalSource: "backend_catalog"' in script
assert '等待 NFC 外设靠近' in script assert '等待 NFC 外设靠近' in script
assert '检测到已绑定的 NFC 后,外设将在本次运行期间持续显示' in script assert '检测到已绑定的 NFC 后,外设将在本次运行期间持续显示' in script
assert 'data-hide-peripheral-card' in script
assert 'data-toggle-peripheral-card' in script
assert 'data-run-peripheral-quick-action' in script
assert 'function hidePeripheralCard(deviceId, cardUid)' in script
assert 'function togglePeripheralCard(deviceId)' in script
assert 'api("peripherals/execute"' in script
assert 'const hiddenPeripheralDevices = new Map()' in script
assert 'detection_count' in script
assert '.v2-nfc-inline' in styles assert '.v2-nfc-inline' in styles
assert '.v2-nfc-dialog' in styles assert '.v2-nfc-dialog' in styles
assert '.v2-nfc-command-list' in styles assert '.v2-nfc-command-list' in styles
assert '.v2-nfc-command-option' in styles assert '.v2-nfc-command-option' in styles
assert '.v2-nfc-select-all' in styles assert '.v2-nfc-select-all' in styles
assert 'self-extension.js?v=nfc-session-58' in html assert '.v2-extension-device-hide' in styles
assert '.v2-extension-quick-controls' in styles
assert 'self-extension.js?v=peripheral-cards-59' in html
assert 'import "../self-extension.css";' in vite_entry assert 'import "../self-extension.css";' in vite_entry
@@ -144,6 +154,8 @@ def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None
assert 'SeatDemoHandler.pets.start()' in server assert 'SeatDemoHandler.pets.start()' in server
assert 'SeatDemoHandler.pets.close()' in server assert 'SeatDemoHandler.pets.close()' in server
assert 'SeatDemoHandler.nfc.close()' in server assert 'SeatDemoHandler.nfc.close()' in server
assert 'path == "/api/extension/peripherals/execute"' in server
assert "execute_quick_peripheral_capability" in server
assert "frame-src 'self' http://*:3001" in server assert "frame-src 'self' http://*:3001" in server
+34
View File
@@ -1403,6 +1403,40 @@ class SelfExtensionTests(unittest.TestCase):
self.assertEqual(operation["domain"], "self_extension") self.assertEqual(operation["domain"], "self_extension")
self.assertTrue(operation["telemetry"]["gateway_acknowledged"]) self.assertTrue(operation["telemetry"]["gateway_acknowledged"])
def test_quick_peripheral_control_reauthorizes_durable_capability(self) -> None:
with TemporaryDirectory() as directory:
service = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(directory, "infrared.sqlite3"),
)
service.sync_peripheral_catalog(
[
{
"id": "quick-light-on",
"status": "learned",
"enabled": True,
"device_id": "quick-light",
"device_name": "快捷灯",
"object_names": ["快捷灯"],
"card_uid": "2CC8B103",
"action": "打开",
"action_names": ["打开", "开启"],
"slot": 8,
}
]
)
result = service.execute_quick_peripheral_capability(
"quick-light-on",
conversation_id="conv-quick-light",
)
self.assertTrue(result["success"])
self.assertEqual(result["execution_mode"], "hardware")
self.assertEqual(result["provider_requested"], "web_console_quick_control")
self.assertEqual(result["peripheral"]["slot"], 8)
service.close()
def test_main_peripheral_sequence_executes_validated_actions_and_delays(self) -> None: def test_main_peripheral_sequence_executes_validated_actions_and_delays(self) -> None:
service = SelfExtensionService(SimulationGateway()) service = SelfExtensionService(SimulationGateway())
catalog = [ catalog = [
@@ -116,3 +116,18 @@ def test_homepage_only_reveals_nfc_devices_detected_in_this_run() -> None:
assert "const detectedNfcIds = new Set(" in unified assert "const detectedNfcIds = new Set(" in unified
assert "detectedNfcIds.has(normalizeCardUid(device.cardUid))" in unified assert "detectedNfcIds.has(normalizeCardUid(device.cardUid))" in unified
assert "检测到已绑定的 NFC 后,外设将在本次运行期间持续显示" in unified assert "检测到已绑定的 NFC 后,外设将在本次运行期间持续显示" in unified
def test_homepage_peripheral_cards_are_single_expand_and_quick_control_surfaces() -> None:
source = SOURCE_PATH.read_text(encoding="utf-8")
unified = _function_source(
source,
" function buildUnifiedExtensionSync() {",
"\n function init()",
)
assert "expandedPeripheralDeviceId === device.id" in unified
assert 'data-hide-peripheral-card="${escapeHtml(device.id)}"' in unified
assert 'data-toggle-peripheral-card="${escapeHtml(device.id)}"' in unified
assert 'data-run-peripheral-quick-action="${escapeHtml(action.id)}"' in unified
assert "hiddenPeripheralDevices.has(device.id)" in unified