3 Commits

Author SHA1 Message Date
Miayui ba5dfcd6da feat(web): add peripheral card quick controls 2026-09-21 16:01:42 +08:00
Miayui ad0c1f4ed1 feat(scenes): add deterministic romantic cinema template 2026-09-21 12:07:49 +08:00
Miayui b3bd90f748 fix(web): reveal NFC peripherals after detection 2026-09-21 11:04:18 +08:00
22 changed files with 1051 additions and 49 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-persistent-57" 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,
+223 -15
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,9 +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: [],
detection_count: 0,
active_nfc_id: null,
last_error: null, last_error: null,
}; };
let peripheralCatalogRevision = null; let peripheralCatalogRevision = null;
@@ -409,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;
@@ -1493,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);
@@ -4006,18 +4068,31 @@
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),
active_extension_names: Array.isArray(value.active_extension_names) active_extension_names: Array.isArray(value.active_extension_names)
? value.active_extension_names.map((item) => String(item || "")).filter(Boolean) ? value.active_extension_names.map((item) => String(item || "")).filter(Boolean)
: [], : [],
detected_nfc_ids: Array.isArray(value.detected_nfc_ids)
? 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} 帧设备能力消息。`,
@@ -4035,11 +4110,14 @@
function applyNfcSnapshot(value, { announce = true } = {}) { function applyNfcSnapshot(value, { announce = true } = {}) {
if (!value || typeof value !== "object") return; if (!value || typeof value !== "object") return;
if (value.pet_cards) applyPetCardSnapshot(value.pet_cards); if (value.pet_cards) applyPetCardSnapshot(value.pet_cards);
if (value.extension_toy) { const extensionToyChanged = value.extension_toy
applyExtensionToySnapshot(value.extension_toy, { announce }); ? applyExtensionToySnapshot(value.extension_toy, { announce })
} : false;
const revision = Number(value.revision ?? -1); const revision = Number(value.revision ?? -1);
if (revision === nfcSnapshot.revision) return; if (revision === nfcSnapshot.revision) {
if (extensionToyChanged) renderUnifiedExtensionView?.();
return;
}
if (Array.isArray(value.legacy_profiles) && value.legacy_profiles.length) { if (Array.isArray(value.legacy_profiles) && value.legacy_profiles.length) {
importLegacyProfiles(value.legacy_profiles); importLegacyProfiles(value.legacy_profiles);
} }
@@ -4112,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();
@@ -4229,6 +4406,12 @@
reader: nfcSnapshot.reader, reader: nfcSnapshot.reader,
card: nfcSnapshot.card, card: nfcSnapshot.card,
}, },
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,
@@ -4327,11 +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 devices = [...grouped.values()]; const detectedNfcIds = new Set(extensionToySnapshot.detected_nfc_ids);
const devices = [...grouped.values()].filter(
(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
@@ -4408,13 +4603,26 @@
</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="link-2-off"></i><div><strong>当前没有已绑定外设</strong><span>外设绑定 NFC 后将持续显示,解除绑定后移除</span></div> <i data-lucide="scan-line"></i><div><strong>等待 NFC 外设靠近</strong><span>检测到已绑定的 NFC 后,外设将在本次运行期间持续显示</span></div>
</div>`} </div>`}
</div> </div>
<footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}"> <footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}">
+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-persistent-57", "/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",
@@ -0,0 +1,17 @@
id: romantic-cinema
name: 浪漫影院模式
aliases: [浪漫观影模式, 浪漫电影模式, 约会影院模式]
description: 完整保留观影模式的座椅、温控、遮光和车内灯光功能,并在运行时确定性加入当前已授权的外设灯光控制。
icon: heart
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 完整保留影院模式的主驾放松按摩;不移动主驾座椅。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 完整保留影院模式的副驾低档通风。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 55}, reason: 完整保留影院模式的副驾靠背姿态。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 完整保留影院模式的副驾腿部支撑。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 完整保留影院模式的座舱恒温。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 完整保留影院模式的舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 完整保留影院模式的低噪声风量。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 0}, reason: 完整保留影院模式的遮光设置。}
- {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: cinema}, reason: 完整保留影院模式的车内影院氛围。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 完整保留影院模式的室内主照明设置。}
- {domain: interior_light, capability: interior_light.set_reading_power, target_scope: all, operation: set, parameters: {state: "off"}, reason: 完整保留影院模式的阅读灯设置。}
@@ -48,7 +48,10 @@ from cognitive_agent.application.scene_composer import (
scene_required_peripheral_refs, scene_required_peripheral_refs,
scene_semantic_guidance, scene_semantic_guidance,
) )
from cognitive_agent.application.scene_templates import normalize_scene_template_name from cognitive_agent.application.scene_templates import (
PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS,
normalize_scene_template_name,
)
from cognitive_agent.application.schedule_intent import is_schedule_stop, needs_mode_schedule from cognitive_agent.application.schedule_intent import is_schedule_stop, needs_mode_schedule
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@@ -1034,15 +1037,53 @@ class CodexAssistantService:
if mode_parts if mode_parts
else None else None
) )
scene_template_plan = ( candidate_scene_template_plan = (
_catalog_exact_scene_template_plan(catalog, mode_parts[0]) _catalog_exact_scene_template_plan(catalog, mode_parts[0])
if mode_parts and not mode_parts[1] and exact_mode_matches == 0 if mode_parts and not mode_parts[1]
else None else None
) )
candidate_scene_template_metadata = (
candidate_scene_template_plan.get("metadata")
if isinstance(candidate_scene_template_plan, Mapping)
else None
)
candidate_scene_template_id = (
str(candidate_scene_template_metadata.get("template_id") or "")
if isinstance(candidate_scene_template_metadata, Mapping)
else ""
)
scene_template_plan = (
candidate_scene_template_plan
if exact_mode_matches == 0
or candidate_scene_template_id
in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
else None
)
scene_template_metadata = (
scene_template_plan.get("metadata")
if isinstance(scene_template_plan, Mapping)
else None
)
preserve_base_external_lights = bool(
isinstance(scene_template_metadata, Mapping)
and str(scene_template_metadata.get("template_id") or "")
in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
)
if preserve_base_external_lights:
# The template already owns every native cinema action. Do not let
# generic romantic-scene defaults replace or append native actions;
# only the deterministic, NFC-authorized light refs may be added.
scene_semantics = scene_context.get("scene_semantics")
if isinstance(scene_semantics, dict):
scene_semantics["required_native_domains"] = []
scene_semantics["required_native_controls"] = []
scene_semantics["native_fallback_requests"] = []
scene_semantics["default_control_bundle_applied"] = False
dynamic_scene_request = bool(mode_parts and needs_mode_schedule(message)) dynamic_scene_request = bool(mode_parts and needs_mode_schedule(message))
scene_peripheral_actions = scene_context.get("peripheral_actions", []) scene_peripheral_actions = scene_context.get("peripheral_actions", [])
template_augmentation_preview = bool( template_augmentation_preview = bool(
scene_template_plan is not None scene_template_plan is not None
and not preserve_base_external_lights
and not dynamic_scene_request and not dynamic_scene_request
and isinstance(scene_peripheral_actions, list) and isinstance(scene_peripheral_actions, list)
and scene_peripheral_actions and scene_peripheral_actions
@@ -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,
@@ -167,6 +169,8 @@ class NfcExtensionToyPublisher:
"active_catalog_revision": None, "active_catalog_revision": None,
"active_extension_names": [], "active_extension_names": [],
"active_target_scopes": [], "active_target_scopes": [],
"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,
@@ -187,11 +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=[],
detection_count=0,
message_count=0, message_count=0,
last_error=None, last_error=None,
) )
@@ -209,11 +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=[],
detection_count=0,
message_count=0, message_count=0,
) )
@@ -336,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():
@@ -346,6 +357,24 @@ 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:
with self._lock:
detected_nfc_ids = list(self._status["detected_nfc_ids"])
if card_uid not in detected_nfc_ids:
self._set_status(
detected_nfc_ids=[*detected_nfc_ids, card_uid],
)
try: try:
catalog_revision = self._catalog_revision() if card_uid else "" catalog_revision = self._catalog_revision() if card_uid else ""
except Exception as exc: # noqa: BLE001 - retry while the card remains except Exception as exc: # noqa: BLE001 - retry while the card remains
@@ -33,6 +33,10 @@ _DOCUMENT_FIELDS = frozenset(
{"id", "name", "aliases", "description", "icon", "operations", "schedule"} {"id", "name", "aliases", "description", "icon", "operations", "schedule"}
) )
# These templates keep their repository-backed native plan byte-for-byte and
# only add currently authorized external-light references at request time.
PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS = frozenset({"romantic-cinema"})
def normalize_scene_template_name(value: object) -> str: def normalize_scene_template_name(value: object) -> str:
"""Normalize an exact user-facing name without enabling fuzzy matching.""" """Normalize an exact user-facing name without enabling fuzzy matching."""
@@ -289,6 +293,7 @@ class SceneTemplateRegistry:
__all__ = [ __all__ = [
"PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS",
"SceneTemplate", "SceneTemplate",
"SceneTemplateRegistry", "SceneTemplateRegistry",
"normalize_scene_template_name", "normalize_scene_template_name",
+122 -16
View File
@@ -116,7 +116,10 @@ from cognitive_agent.application.native_can_runtime import ( # noqa: E402
NativeCanExecutor, NativeCanExecutor,
NativeCanRuntime, NativeCanRuntime,
) )
from cognitive_agent.application.scene_templates import SceneTemplateRegistry # noqa: E402 from cognitive_agent.application.scene_templates import ( # noqa: E402
PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS,
SceneTemplateRegistry,
)
from cognitive_agent.application.schedule_intent import ( # noqa: E402 from cognitive_agent.application.schedule_intent import ( # noqa: E402
is_schedule_stop, is_schedule_stop,
needs_mode_schedule, needs_mode_schedule,
@@ -745,14 +748,14 @@ class SeatDemoService(ModeScheduleServiceMixin):
merged[existing] = action merged[existing] = action
return merged return merged
def _n7_cinema_base_scene( def _n7_cinema_base_profile(
self, self,
incoming_scene: Mapping[str, Any], incoming_scene: Mapping[str, Any],
) -> dict[str, Any] | None: ) -> ModeProfile | None:
if self._is_n7_cinema_base_scene(incoming_scene): if self._is_n7_cinema_base_scene(incoming_scene):
return None return None
vehicle_id = self.vehicle_profiles.default().id vehicle_id = self.vehicle_profiles.default().id
candidates: list[tuple[int, dict[str, Any]]] = [] candidates: list[tuple[int, ModeProfile]] = []
for public_profile in self.mode_profiles.public_modes(vehicle_id): for public_profile in self.mode_profiles.public_modes(vehicle_id):
try: try:
profile = self.mode_profiles.get_profile( profile = self.mode_profiles.get_profile(
@@ -774,12 +777,26 @@ class SeatDemoService(ModeScheduleServiceMixin):
): ):
continue continue
priority = 0 if str(source_scene.get("id") or "") == "cinema" else 1 priority = 0 if str(source_scene.get("id") or "") == "cinema" else 1
candidates.append((priority, deepcopy(dict(source_scene)))) candidates.append((priority, profile))
if not candidates: if not candidates:
return None return None
candidates.sort(key=lambda item: (item[0], str(item[1].get("id") or ""))) candidates.sort(key=lambda item: (item[0], item[1].id))
return candidates[0][1] return candidates[0][1]
def _n7_cinema_base_scene(
self,
incoming_scene: Mapping[str, Any],
) -> dict[str, Any] | None:
profile = self._n7_cinema_base_profile(incoming_scene)
if profile is None:
return None
source_scene = (
profile.command_flow.get("source_scene")
if isinstance(profile.command_flow, Mapping)
else None
)
return deepcopy(dict(source_scene)) if isinstance(source_scene, Mapping) else None
@staticmethod @staticmethod
def _is_n7_cinema_video_launch_action(action: Mapping[str, Any]) -> bool: def _is_n7_cinema_video_launch_action(action: Mapping[str, Any]) -> bool:
parameters = action.get("parameters") parameters = action.get("parameters")
@@ -1428,9 +1445,22 @@ class SeatDemoService(ModeScheduleServiceMixin):
requests = self.mode_profiles.requests_for_profiles([profile]) requests = self.mode_profiles.requests_for_profiles([profile])
if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC: if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC:
transfer_actions = self._local_cinema_n7_transfer_actions( stored_transfer_actions = (
profile.name, profile.command_flow.get("n7_transfer_actions")
profile.instruction, if isinstance(profile.command_flow, Mapping)
else None
)
transfer_actions = (
[
deepcopy(dict(action))
for action in stored_transfer_actions[:256]
if isinstance(action, Mapping)
]
if isinstance(stored_transfer_actions, list)
else self._local_cinema_n7_transfer_actions(
profile.name,
profile.instruction,
)
) )
transfer_selectors = { transfer_selectors = {
self._n7_transfer_operation_selector( self._n7_transfer_operation_selector(
@@ -2020,8 +2050,22 @@ class SeatDemoService(ModeScheduleServiceMixin):
raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。") raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。")
if schedule is not None: if schedule is not None:
operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations] operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations]
source_template_id = (
str(source_draft.get("source_template_id") or "").strip()
if isinstance(source_draft, Mapping)
else ""
)
preview_transfer_candidates = source_result.get("n7_transfer_candidates")
n7_transfer_actions = ( n7_transfer_actions = (
self._local_cinema_n7_transfer_actions( [
deepcopy(dict(action))
for action in preview_transfer_candidates[:256]
if isinstance(action, Mapping)
]
if schedule is None
and source_template_id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
and isinstance(preview_transfer_candidates, list)
else self._local_cinema_n7_transfer_actions(
clean_name, clean_name,
clean_instruction, clean_instruction,
) )
@@ -6966,13 +7010,34 @@ class SeatDemoService(ModeScheduleServiceMixin):
mode_name, mode_name,
vehicle_id=vehicle_profile.id, vehicle_id=vehicle_profile.id,
) )
if scene_template is not None and existing_profile is not None: preserve_base_external_lights = bool(
scene_template is not None
and scene_template.id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
)
if (
scene_template is not None
and existing_profile is not None
and not preserve_base_external_lights
):
raise ValueError("同名功能模式已创建,请重新提交以载入最新模式。") raise ValueError("同名功能模式已创建,请重新提交以载入最新模式。")
source_profile = ( source_profile = (
existing_profile existing_profile
if not requested_instruction and source_draft is None if not requested_instruction and source_draft is None
and not preserve_base_external_lights
else None else None
) )
if (
source_profile is None
and source_draft is None
and not requested_instruction
and preserve_base_external_lights
):
source_profile = self._n7_cinema_base_profile(
{
"id": scene_template.id,
"name": mode_name,
}
)
if source_profile is None and source_draft is None and not requested_instruction and scene_template is None: if source_profile is None and source_draft is None and not requested_instruction and scene_template is None:
source_name = mode_creation_source_name(mode_name) source_name = mode_creation_source_name(mode_name)
if source_name != mode_name: if source_name != mode_name:
@@ -7000,7 +7065,18 @@ class SeatDemoService(ModeScheduleServiceMixin):
# of appending a model prompt to the user's utterance. The latter was # of appending a model prompt to the user's utterance. The latter was
# being parsed as a second control clause on the assistant's local fast # being parsed as a second control clause on the assistant's local fast
# path, turning a valid nine-action mode into an incomplete draft. # path, turning a valid nine-action mode into an incomplete draft.
source_utterance = requested_instruction or f"进入{draft_mode_name}" draft_instruction = requested_instruction or f"进入{draft_mode_name}"
inherited_n7_cinema_profile = bool(
source_profile is not None
and scene_template is not None
and scene_template.id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
and source_profile.sync_origin == MQTT_MODE_SCENE_INGRESS_TOPIC
)
source_utterance = (
f"进入{source_profile.name}"
if inherited_n7_cinema_profile
else draft_instruction
)
if requested_instruction and selected_seats is not None and len(selected_seats) == 1: if requested_instruction and selected_seats is not None and len(selected_seats) == 1:
# Qualify only explicit seat-device clauses, never an HVAC/window # Qualify only explicit seat-device clauses, never an HVAC/window
# zone or unrelated control. General one-shot controls keep their # zone or unrelated control. General one-shot controls keep their
@@ -7056,7 +7132,19 @@ class SeatDemoService(ModeScheduleServiceMixin):
self._persist(result) self._persist(result)
return result return result
try: try:
if scene_template is not None and selected_seats is not None: preserve_unscoped_template_seats = bool(
scene_template is not None
and scene_template.id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
and resolve_explicit_seat_scopes(
utterance,
assume_seat_context=True,
) is None
)
if (
scene_template is not None
and selected_seats is not None
and not preserve_unscoped_template_seats
):
assistant_plan = project_seed_plan(assistant_plan, selected_seats) assistant_plan = project_seed_plan(assistant_plan, selected_seats)
source_result = self._process_once( source_result = self._process_once(
source_utterance, source_utterance,
@@ -7070,7 +7158,9 @@ class SeatDemoService(ModeScheduleServiceMixin):
direct_mode_profile_id=( direct_mode_profile_id=(
source_profile.id if source_profile is not None else None source_profile.id if source_profile is not None else None
), ),
mode_creation_scopes=selected_seats, mode_creation_scopes=(
None if preserve_unscoped_template_seats else selected_seats
),
assistant_plan=assistant_plan, assistant_plan=assistant_plan,
# ``source_utterance`` is synthesized for a name-only, unknown # ``source_utterance`` is synthesized for a name-only, unknown
# scene. It must not fall back to substring matching (for example # scene. It must not fall back to substring matching (for example
@@ -7086,7 +7176,11 @@ class SeatDemoService(ModeScheduleServiceMixin):
# already passed the ordinary text/target authorization path. # already passed the ordinary text/target authorization path.
# Its old display name cannot veto an explicitly requested new # Its old display name cannot veto an explicitly requested new
# seat. This override is internal, never accepted from payloads. # seat. This override is internal, never accepted from payloads.
scene_scope_utterance="" if source_draft is not None else utterance, scene_scope_utterance=(
""
if source_draft is not None or preserve_unscoped_template_seats
else utterance
),
) )
except ModeCreationSeatPolicyError as exc: except ModeCreationSeatPolicyError as exc:
return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc)) return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc))
@@ -7219,6 +7313,16 @@ class SeatDemoService(ModeScheduleServiceMixin):
typed_source_actions = [ typed_source_actions = [
action for action in source_actions if isinstance(action, dict) action for action in source_actions if isinstance(action, dict)
] if isinstance(source_actions, list) else [] ] if isinstance(source_actions, list) else []
if (
inherited_n7_cinema_profile
and source_result.get("status") == "partially_succeeded"
and typed_source_actions
and source_result.get("n7_transfer_candidates")
):
# N7-only actions are intentionally deferred during a dry-run. They
# remain part of the received cinema base and are persisted as
# transfer actions, so their presence is not a partial draft.
source_result["status"] = "succeeded"
if ( if (
source_result.get("status") == "succeeded" source_result.get("status") == "succeeded"
and generation_source and generation_source
@@ -7228,6 +7332,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
"scene_template_copy", "scene_template_copy",
} }
and assistant_plan is not None and assistant_plan is not None
and not inherited_n7_cinema_profile
and ( and (
self._scene_plan_signatures(assistant_plan) self._scene_plan_signatures(assistant_plan)
!= self._scene_action_signatures(typed_source_actions) != self._scene_action_signatures(typed_source_actions)
@@ -7372,9 +7477,10 @@ class SeatDemoService(ModeScheduleServiceMixin):
if source_draft is not None if source_draft is not None
else 0 else 0
) )
source_result["utterance"] = draft_instruction
source_result["mode_profile_draft"] = { source_result["mode_profile_draft"] = {
"name": draft_mode_name, "name": draft_mode_name,
"instruction": source_utterance, "instruction": draft_instruction,
"source_request_id": source_request_id, "source_request_id": source_request_id,
"vehicle_id": vehicle_profile.id, "vehicle_id": vehicle_profile.id,
"domains": domains, "domains": domains,
@@ -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-persistent-57", 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-persistent-57")}"', 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)
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -65,6 +65,204 @@ def _learned_light_power_on() -> dict[str, object]:
} }
def _received_cinema_scene() -> dict[str, object]:
actions = [
{
"type": "window_position",
"at_ms": 0,
"domain": "window",
"capability": "window.set_position",
"target_scope": "front_left",
"operation": "set",
"parameters": {"position_pct": 0},
},
{
"type": "climate_temperature",
"at_ms": 0,
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "vehicle",
"operation": "set",
"parameters": {"temperature_c": 25},
},
{
"type": "seat_preset",
"at_ms": 0,
"domain": "local_vehicle",
"capability": "local_scene.seat_preset",
"target_scope": "vehicle",
"operation": "set",
"parameters": {"targets_pulse": [246, 112, 405, 342]},
},
{
"type": "android_app_launch",
"at_ms": 3000,
"domain": "android",
"capability": "android.launch_app",
"target_scope": "headunit",
"operation": "launch",
"parameters": {
"package_name": "com.byd.videoplay",
"activity_name": "com.byd.videoplay.activity.WelComeActivity",
},
},
]
return {
"id": "cinema",
"label": "影院模式",
"name": "影院模式",
"description": "接收到的车机影院模式。",
"icon": "film",
"aliases": ["观影模式"],
"origin": "headunit",
"deletable": True,
"behavior": "static",
"domains": ["window", "climate", "local_vehicle", "android"],
"operation_count": len(actions),
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
"profile_revision": "received-cinema-v1",
"actions": actions,
}
def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
tmp_path: Path,
) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = tmp_path / "runs"
service.mode_profiles = ModeProfileRegistry(
REPOSITORY_ROOT / "config" / "modes",
user_root=tmp_path / "modes",
)
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=tmp_path / "infrared.sqlite3",
)
extension.sync_peripheral_catalog([_learned_light_power_on()])
received_cinema = _received_cinema_scene()
service.import_n7_mode_scene(received_cinema)
stale_profile = service.mode_profiles.save_mode(
"浪漫影院模式",
[
{
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "vehicle",
"operation": "set",
"parameters": {"temperature_c": 30},
}
],
vehicle_id="denza_n7",
instruction="进入浪漫影院模式",
)
catalog = AssistantCapabilityCatalog(
service,
peripheral_provider=extension.list_peripheral_routing_capabilities,
)
handler = object.__new__(SeatDemoHandler)
handler.service = service
handler.extension = extension
handler.nfc = SimpleNamespace(snapshot=lambda: {"card": None})
handler.vehicle_display_sync = VehicleDisplaySync()
client = MagicMock()
client.available = True
assistant = CodexAssistantService(client)
snapshot = catalog.snapshot(vehicle_id="denza_n7")
result: dict[str, object] = {}
def execute_control(
instruction: str,
progress,
assistant_plan: dict[str, object],
) -> dict[str, object]:
nonlocal result
arguments = {
"utterance": instruction,
"provider": "local",
"conversation_id": "conv-romantic-cinema-template",
"execution_mode": "hardware",
"vehicle_id": "denza_n7",
"simulation_fault": "normal",
"hardware_access_bypass": True,
"reasoning_effort": "low",
"control_transport": "wired_can",
"assistant_plan": assistant_plan,
}
result = handler._preview_mode_with_scene_peripherals(
"/api/requests/preview",
arguments,
assistant_plan,
on_commands_validated=progress,
)
return result
try:
with patch.object(
extension,
"execute_gateway",
side_effect=AssertionError("mode creation must not emit infrared"),
):
response = assistant.stream(
"创建浪漫影院模式",
conversation_id="conv-romantic-cinema-template",
principal="romantic-cinema-template-test",
history=[],
emit=lambda _event: True,
execute_control=execute_control,
capability_snapshot=snapshot,
capability_context=catalog.compact_context(snapshot),
scene_capability_context=catalog.scene_composition_context(snapshot),
control_terms=catalog.control_terms(snapshot),
)
assert response["success"] is True
assert response["route"] == "scene_template"
client.start_structured_turn.assert_not_called()
client.start_turn.assert_not_called()
assert result["status"] == "succeeded"
assert result["mode_profile_draft"]["generation_source"] == "scene_template_copy"
assert result["mode_profile_draft"]["existing_mode_id"] == stale_profile.id
assert result["mode_profile_draft"]["operation_count"] == 3
native_actions = {
(
action["capability"],
action["target"].get("instance") or action["target"].get("zone"),
): action["parameters"]
for action in result["intent_ir"]["spec"]["actions"]
if action["domain"] != "self_extension"
}
assert native_actions == {
(action["capability"], action["target_scope"]): action["parameters"]
for action in received_cinema["actions"]
if action["domain"] in {"window", "climate"}
}
assert result["n7_transfer_candidates"] == received_cinema["actions"][2:]
assert [action["slot"] for action in result["infrared_actions"]] == [1]
draft = result["mode_profile_draft"]
saved = service.save_mode_profile_card(
stale_profile.id,
draft["name"],
draft["instruction"],
draft["source_request_id"],
draft["vehicle_id"],
)
profile = service.get_mode_profile_card(saved["mode_profile"]["id"], "denza_n7")
saved_selectors = {
(operation.capability, operation.target_scope)
for operation in profile.operations
}
assert saved_selectors == {
(action["capability"], action["target_scope"])
for action in received_cinema["actions"]
} | {("infrared.emit", "romance-light")}
_local_requests, transfer_actions = service._mode_requests_and_n7_transfers(profile)
assert transfer_actions == received_cinema["actions"][2:]
finally:
service.close()
def test_scene_composer_mixed_can_ir_preview_save_and_revalidate( def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@@ -165,7 +165,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
templates = snapshot["scene_templates"] templates = snapshot["scene_templates"]
assert isinstance(templates, list) assert isinstance(templates, list)
assert len(templates) == 16 assert len(templates) == 17
assert all(template["assistant_callable"] is True for template in templates) assert all(template["assistant_callable"] is True for template in templates)
with patch.object( with patch.object(
@@ -211,7 +211,9 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
else: else:
expected_requests = [ expected_requests = [
request for request in expected_requests request for request in expected_requests
if request["domain"] != "seat" or request["target_scope"] == "passenger" if template["id"] == "romantic-cinema"
or request["domain"] != "seat"
or request["target_scope"] == "passenger"
] ]
assert draft["operation_count"] == len(expected_requests) assert draft["operation_count"] == len(expected_requests)
expected = { expected = {
@@ -246,6 +248,23 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
assert not list(user_root.rglob("*.yaml")) assert not list(user_root.rglob("*.yaml"))
def test_romantic_cinema_template_is_an_exact_copy_of_all_cinema_functions(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
) -> None:
service, _catalog, _user_root = hybrid_service
cinema = service.scene_templates.get_template("cinema")
romantic_cinema = service.scene_templates.get_template("romantic-cinema")
assert len(cinema.operations) == len(romantic_cinema.operations) == 11
assert [
(operation.domain, operation.capability, operation.target_scope, operation.parameters)
for operation in romantic_cinema.operations
] == [
(operation.domain, operation.capability, operation.target_scope, operation.parameters)
for operation in cinema.operations
]
def test_viewing_template_seeds_a_complete_unbound_preview_without_codex( def test_viewing_template_seeds_a_complete_unbound_preview_without_codex(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
) -> None: ) -> None:
@@ -372,7 +372,7 @@ def test_common_scene_templates_are_read_only_callable_catalog_entries(
templates = snapshot["scene_templates"] templates = snapshot["scene_templates"]
assert snapshot["source_status"]["scene_templates"] == "ready" assert snapshot["source_status"]["scene_templates"] == "ready"
assert len(templates) == 16 assert len(templates) == 17
assert {item["name"] for item in templates} >= { assert {item["name"] for item in templates} >= {
"观影模式", "观影模式",
"露营模式", "露营模式",
@@ -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
@@ -143,13 +146,41 @@ def test_each_detection_publishes_three_frames_per_device_and_then_stops() -> No
nfc["card"] = None nfc["card"] = None
assert wait_until(lambda: service.snapshot()["state"] == "waiting_for_nfc") assert wait_until(lambda: service.snapshot()["state"] == "waiting_for_nfc")
assert service.snapshot()["detected_nfc_ids"] == ["E113B203"]
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()
def test_detected_devices_remain_latched_until_publisher_stops() -> None:
nfc: dict[str, Any] = {"card": {"action": "present", "uid": "E113B203"}}
service = NfcExtensionToyPublisher(
nfc_provider=lambda: nfc,
catalog_provider=lambda: [capability("light-on")],
publisher=lambda _payload: {"delivery_status": "queued"},
burst_window_s=0.03,
poll_interval_s=0.005,
)
service.start()
try:
assert wait_until(
lambda: service.snapshot()["detected_nfc_ids"] == ["E113B203"]
)
nfc["card"] = None
assert wait_until(lambda: service.snapshot()["state"] == "waiting_for_nfc")
assert service.snapshot()["detected_nfc_ids"] == ["E113B203"]
finally:
service.close()
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:
nfc = {"card": {"action": "present", "uid": "E113B203"}} nfc = {"card": {"action": "present", "uid": "E113B203"}}
publications: list[dict[str, Any]] = [] publications: list[dict[str, Any]] = []
@@ -168,6 +199,7 @@ def test_no_matching_ability_publishes_nothing() -> None:
assert wait_until( assert wait_until(
lambda: service.snapshot()["state"] == "no_matching_extension" lambda: service.snapshot()["state"] == "no_matching_extension"
) )
assert service.snapshot()["detected_nfc_ids"] == ["E113B203"]
time.sleep(0.05) time.sleep(0.05)
assert publications == [] assert publications == []
finally: finally:
@@ -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-persistent-57", "/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"),
): ):
+15 -3
View File
@@ -65,15 +65,25 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
assert 'async function hydratePeripheralCatalog()' in script assert 'async function hydratePeripheralCatalog()' in script
assert 'api("peripherals")' in script assert 'api("peripherals")' in script
assert 'signalSource: "backend_catalog"' in script assert 'signalSource: "backend_catalog"' in script
assert '当前没有已绑定外设' 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-persistent-57' 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
@@ -701,6 +701,50 @@ def test_romantic_template_always_adds_authorized_decorative_lights() -> None:
assert "ir:flash-close-00000000003" not in _peripheral_refs(observed[0]) assert "ir:flash-close-00000000003" not in _peripheral_refs(observed[0])
def test_romantic_cinema_template_keeps_native_base_and_adds_lights_without_model() -> None:
client = StructuredSceneClient()
assistant = CodexAssistantService(client)
template = _scene_template(
template_id="romantic-cinema",
name="浪漫影院模式",
aliases=["浪漫观影模式"],
)
observed: list[dict[str, object]] = []
def execute(
_instruction: str,
_progress: Callable[[dict[str, object]], None],
assistant_plan: Mapping[str, object],
) -> dict[str, object]:
observed.append(deepcopy(dict(assistant_plan)))
return _preview_result("浪漫影院模式")
result, _events = _invoke(
assistant,
"创建浪漫影院模式",
execute,
conversation_id="conv-romantic-cinema-deterministic",
catalog=_catalog_with_templates(template),
scene_context=_scene_context_with_flashing_light(),
)
assert result["success"] is True
assert result["route"] == "scene_template"
assert client.structured_calls == []
assert client.legacy_calls == []
native_requests = [
request
for request in observed[0]["requests"]
if request.get("domain") != "self_extension"
]
assert native_requests == template["plan"]["requests"]
assert _peripheral_refs(observed[0]) == [
"ir:flash-open-000000000001",
"ir:ambient-open-0000000001",
"ir:strip-open-000000000001",
]
def test_mid_autumn_cloud_composition_adds_required_native_and_external_controls() -> None: def test_mid_autumn_cloud_composition_adds_required_native_and_external_controls() -> None:
client = StructuredSceneClient() client = StructuredSceneClient()
assistant = CodexAssistantService(client) assistant = CodexAssistantService(client)
+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 = [
@@ -102,3 +102,32 @@ def test_device_card_surfaces_extension_toy_mqtt_delivery() -> None:
assert "extensionToySnapshot.last_error" in apply_toy assert "extensionToySnapshot.last_error" in apply_toy
assert "applyExtensionToySnapshot(value.extension_toy" in apply_nfc assert "applyExtensionToySnapshot(value.extension_toy" in apply_nfc
assert "正在准备 extension/toy 消息" in apply_nfc assert "正在准备 extension/toy 消息" in apply_nfc
def test_homepage_only_reveals_nfc_devices_detected_in_this_run() -> None:
source = SOURCE_PATH.read_text(encoding="utf-8")
unified = _function_source(
source,
" function buildUnifiedExtensionSync() {",
"\n function init()",
)
assert "detected_nfc_ids" in source
assert "const detectedNfcIds = new Set(" in unified
assert "detectedNfcIds.has(normalizeCardUid(device.cardUid))" 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