Compare commits
3 Commits
448c8b31a0
...
ba5dfcd6da
| Author | SHA1 | Date | |
|---|---|---|---|
| ba5dfcd6da | |||
| ad0c1f4ed1 | |||
| b3bd90f748 |
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))}">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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", [])),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user