feat(n7web): enhance control workflows and status feedback

This commit is contained in:
Miayui
2026-09-16 12:52:07 +08:00
parent 84a3cd73bb
commit 0e4328f548
45 changed files with 7317 additions and 1001 deletions
+2
View File
@@ -14,6 +14,8 @@ tmp/*.log
.codex_tmp/
/build/
/output/
/apps/web-console/node_modules/
/apps/web-console/dist/
/tmp/imagegen/
firmware/t2g/Seat_Controller_demo/Tests/manual_hil/logs/
firmware/t2g/Seat_Controller_demo/Tests/manual_hil/seat_function_runtime/
+58
View File
@@ -8,6 +8,29 @@ Web 控制台与同源 API 由同一个 Python 服务提供。本地启动:
.\.venv\Scripts\python.exe .\tools\start_n7web.py
```
启动脚本会先安装缺失的前端依赖并执行 Vue 3 + Vite 生产构建,然后启动 `8000` 主服务和独立的 `8090` 只读模式卡服务。已经有可复用的 `apps/web-console/dist` 时,可使用 `--skip-web-build` 跳过本次构建。
### Vue 3 + Vite 前端
前端根应用位于 `apps/web-console/src/App.vue`,入口为 `src/main.js`。Vue 和 Vite 均由 npm 锁定版本管理,浏览器不再依赖 CDN 或全局 `window.Vue`。现有 MQTT、NFC、语音、模式卡和车辆控制脚本作为兼容运行层继续使用原 DOM 契约,由 Vue 根应用承载页面结构。
单独进行前端开发时,先保持 Python 主服务运行,再启动 Vite:
```powershell
cd .\apps\web-console
npm ci
npm run dev
```
开发页面为 `http://127.0.0.1:5173/`,`/api` 与 `/artifacts` 会代理到 `http://127.0.0.1:8000`。生成生产静态资源:
```powershell
cd .\apps\web-console
npm run build
```
构建输出位于 `apps/web-console/dist`;Python 主服务检测到该目录后会优先提供生产构建。上述流程不会改变主页的 `8000` 端口,也不会改变模式卡服务的 `8090` 端口。
Windows PowerShell 未安装 `make` 时可直接运行:
```powershell
@@ -186,6 +209,41 @@ config/gesture/default.yaml
同步由模式注册表的创建、更新、删除、恢复和显式重载事件触发,不会持续扫描目录。每次变更只重新生成并原子替换一份小型 JSON;独立目录服务在收到 HTTP 请求时才读取它。因此空闲时的额外开销主要是一个等待请求的 Python 进程和一个 UDP 发现线程,而不是持续的 CPU 或磁盘轮询。
前端通过 `POST /api/mode-profiles` 新增模式卡片,或通过 `POST /api/mode-profiles/{id}` 修改模式卡控制流后,主服务会先完成 YAML 持久化和 `/n7/scenes` 原子同步,再从同一份目录投影中按模式 ID 提取一个 `scenes[]` 元素,并在 1 秒内以约 333 ms 间隔向 MQTT `from_pc/scenes` 发布 3 次。每帧都是完全相同的更新后单卡 JSON,不包含目录外层或其他已有卡片,QoS 沿用车辆 MQTT 配置且 `retain=false`。删除和页面刷新只更新原有持久化/目录链路,不会触发该 Topic。
检测到已绑定外设的 NFC 卡时,主服务会按英文稳定 `device_id` 对已学习且启用的红外能力分组,并向 MQTT `extension/toy` 发布 3 帧相同的设备能力消息(约在 0、333、666 ms 发布,QoS 1、`retain=false`)。`extension_name` 使用中文显示名,`actions[].target_scope` 使用英文 `device_id`;卡片持续停留不会重复发送,移开后再次检测才会重新发送。该消息仅广播能力目录,不会触发红外发射。
车端需要控制这些 TCP 红外外设时,向同一个车辆 MQTT Broker 的 `extension/ctl` 发布 QoS 1、`retain=false` 的 JSON。PC 主服务固定订阅该 Topic;消息外层必须且只能包含 `domain`、`operation_count`、`actions`,其中 `domain` 为 `self_extension`,`operation_count` 必须与动作数量一致(1–64),`actions` 使用 `/n7/scenes` 中的红外动作格式:
```json
{
"domain": "self_extension",
"operation_count": 2,
"actions": [
{
"type": "infrared_emit",
"at_ms": 0,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": "toy-light-001",
"operation": "emit",
"parameters": {"action": "打开", "slot": 2}
},
{
"type": "infrared_emit",
"at_ms": 1000,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": "toy-light-001",
"operation": "emit",
"parameters": {"action": "切换为蓝色", "slot": 5}
}
]
}
```
服务在执行首条动作前会对完整控制流做原子鉴权:每一项都必须与本机目录中“已学习、已启用、具有持久 NFC 绑定”的 `device_id + action + slot` 完全一致;NFC 卡此时无需仍在感应区。执行前还会逐项重新鉴权,以阻止排队期间已被禁用或删除的能力。动作按 `at_ms` 排序后通过本机 TCP 红外网关串行下发,相邻发射至少间隔 800 ms;任一动作失败即停止剩余控制流。为避免重连误执行,retained 消息和 MQTT `dup` 重投消息都会被拒绝,消息体上限为 256 KiB。接收、鉴权、排队、执行结果和拒绝原因可在 `GET /api/extension/health` 的 `extension_control` 字段查看。
如果只需单独启动 UDP 发现和只读目录服务,也可以在另一个 PowerShell 终端运行:
```powershell
+48 -16
View File
@@ -6136,6 +6136,7 @@ function ensureModeActionPopover() {
const header = document.createElement("header");
const headingCopy = document.createElement("span");
const eyebrow = document.createElement("small");
eyebrow.dataset.modeActionEyebrow = "true";
eyebrow.textContent = "执行动作预览";
const title = document.createElement("strong");
title.dataset.modeActionTitle = "true";
@@ -6153,7 +6154,12 @@ function ensureModeActionPopover() {
function renderModeActionPopover(mode) {
const popover = ensureModeActionPopover();
const actions = modeExecutionActionViews(mode);
const commandFlow = modeEditorReadableCommands(mode?.command_flow);
const usesLatestCommandFlow = commandFlow.length > 0;
const actions = usesLatestCommandFlow ? commandFlow : modeExecutionActionViews(mode);
$(`[data-mode-action-eyebrow]`, popover).textContent = usesLatestCommandFlow
? "最新控制指令流"
: "执行动作预览";
$("[data-mode-action-title]", popover).textContent = mode?.name || mode?.id || "功能模式";
$("[data-mode-action-count]", popover).textContent = actions.length
? `${actions.length} 步`
@@ -6163,7 +6169,7 @@ function renderModeActionPopover(mode) {
if (!actions.length) {
const empty = document.createElement("li");
empty.className = "mode-chip-action-empty";
empty.textContent = "执行动作明细暂未加载,请刷新后重试。";
empty.textContent = "控制指令流暂未加载,请刷新后重试。";
list.append(empty);
return popover;
}
@@ -6175,18 +6181,28 @@ function renderModeActionPopover(mode) {
const copy = document.createElement("span");
copy.className = "mode-chip-action-copy";
const actionName = document.createElement("strong");
actionName.textContent = action.action;
actionName.textContent = usesLatestCommandFlow
? String(action.category || "控制指令")
: action.action;
const metadata = document.createElement("small");
const device = document.createElement("span");
device.innerHTML = "<b>设备</b>";
device.append(document.createTextNode(action.device));
const target = document.createElement("span");
target.innerHTML = "<b>目标</b>";
target.append(document.createTextNode(action.target));
const parameters = document.createElement("span");
parameters.innerHTML = "<b>参数</b>";
parameters.append(document.createTextNode(action.parameters));
metadata.append(device, target, parameters);
const appendMetadata = (label, value) => {
const text = String(value || "").trim();
if (!text) return;
const item = document.createElement("span");
const key = document.createElement("b");
key.textContent = label;
item.append(key, document.createTextNode(text));
metadata.append(item);
};
if (usesLatestCommandFlow) {
appendMetadata("语义", action.semantic || action.operation_ref);
appendMetadata("实际", action.command);
appendMetadata("通道", action.channel);
} else {
appendMetadata("设备", action.device);
appendMetadata("目标", action.target);
appendMetadata("参数", action.parameters);
}
copy.append(actionName, metadata);
item.append(step, copy);
list.append(item);
@@ -6642,10 +6658,13 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) {
closeModeGallery({ restoreFocus: false });
const runButton = $("#runButton");
const input = $("#utterance");
const assistantModeHandle = assistant?.beginModeExecution?.(mode, {
command: modeCommand,
}) || null;
runButton.disabled = true;
setModeChipsDisabled(true);
setModeExecutionState(mode.id, "running");
assistant?.open?.();
if (!assistantModeHandle) assistant?.open?.();
input.value = modeCommand;
input.dispatchEvent(new Event("input", { bubbles: true }));
setRunStatus("processing", "MODE COMMAND FLOW");
@@ -6677,7 +6696,13 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) {
? `${mode.name}执行完成`
: `${mode.name}执行未完成`;
setModeExecutionState(mode.id, completed ? "succeeded" : "failed");
assistant?.announceModeExecution?.(mode, payload, { command: modeCommand });
if (assistantModeHandle && assistant?.completeModeExecution) {
assistant.completeModeExecution(assistantModeHandle, mode, payload, {
command: modeCommand,
});
} else {
assistant?.announceModeExecution?.(mode, payload, { command: modeCommand });
}
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
showToast(completed
? `${mode.name}已直接执行保存的控制指令流`
@@ -6686,7 +6711,14 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) {
setModeExecutionState(mode.id, "failed");
setRunStatus("failed", "MODE FAILED");
$("#resultTitle").textContent = `${mode.name}执行失败`;
assistant?.announceModeExecution?.(mode, null, { command: modeCommand, error });
if (assistantModeHandle && assistant?.completeModeExecution) {
assistant.completeModeExecution(assistantModeHandle, mode, null, {
command: modeCommand,
error,
});
} else {
assistant?.announceModeExecution?.(mode, null, { command: modeCommand, error });
}
showToast(error.message || "模式执行失败");
} finally {
if (input.value === modeCommand) {
+1 -1
View File
@@ -73,7 +73,7 @@
bottom: auto;
grid-column: 1;
grid-row: 3;
margin-left: 10px;
margin-left: 0;
border-radius: 18px;
box-shadow: 0 14px 38px rgba(25, 44, 82, 0.12);
transform-origin: left center;
+100 -5
View File
@@ -544,7 +544,27 @@
if (capability.startsWith("seat.")) {
const target = String(operation.target?.zone || operation.target?.instance || "")
.replaceAll("_", "-");
return `${["passenger", "front-right"].includes(target) ? "副驾" : "主驾"}座椅`;
const seat = ["passenger", "front-right"].includes(target) ? "副驾座椅" : "主驾座椅";
const component = {
"seat.set_longitudinal_position": "前后位置",
"seat.set_cushion_tilt": "坐盆",
"seat.set_backrest_recline": "靠背",
"seat.set_legrest_height": "腿托",
"seat.set_legrest_extension": "腿托",
}[capability];
if (component) return `${seat}${component}`;
if (capability === "seat.set_driver_axis_native") {
const axis = String(operation.parameters?.axis || "").replaceAll("_", "-");
const axisComponent = {
horizontal: "前后位置",
height: "坐盆",
basin: "坐盆",
backrest: "靠背",
legrest: "腿托",
}[axis];
if (axisComponent) return `${seat}${axisComponent}`;
}
return seat;
}
if (capability.startsWith("outlet.")) return "空调风口";
return String(fallback || capability || "控制指令");
@@ -589,6 +609,24 @@
String(presentation?.operation?.id || ""),
presentation?.operation || {},
]));
const operationByRef = new Map();
const configurations = result.function_configs && typeof result.function_configs === "object"
? Object.values(result.function_configs)
: [];
if (result.function_config && typeof result.function_config === "object") {
configurations.push(result.function_config);
}
configurations.forEach((configuration) => {
const operations = Array.isArray(configuration?.spec?.operations)
? configuration.spec.operations
: [];
operations.forEach((operation) => {
const operationRef = String(operation?.id || "");
if (operationRef && !operationByRef.has(operationRef)) {
operationByRef.set(operationRef, operation);
}
});
});
let commands = [];
try {
const sharedCommands = window.CognitiveAgentConsoleV2?.getParsedCanCommands?.(result);
@@ -599,10 +637,19 @@
if (!commands.length) return fallbackAssistantControlCommands(result);
return commands.map((item, index) => {
const operation = projectionByRef.get(String(item.operationRef || ""))
|| operationByRef.get(String(item.operationRef || ""))
|| projections[index]?.operation
|| {};
const pendingDelivery = result.status === "processing"
? {
deliveryState: "submitting",
deliveryLabel: "正在下发",
deliveryDetail: "",
}
: {};
return {
...item,
...pendingDelivery,
category: assistantControlCategory(operation, item.category),
operation,
};
@@ -1459,14 +1506,54 @@
}
}
function announceModeExecution(profile, result = null, { command = "", error = null } = {}) {
function modeExecutionPreviewResult(profile) {
const flow = profile?.command_flow && typeof profile.command_flow === "object"
? profile.command_flow
: {};
return {
status: "processing",
function_configs: flow.function_configs && typeof flow.function_configs === "object"
? flow.function_configs
: {},
command_plans: flow.plans && typeof flow.plans === "object" ? flow.plans : {},
control_transport: String(flow.control_transport || ""),
execution_report: null,
operation_results: [],
};
}
function beginModeExecution(profile, { command = "" } = {}) {
const modeName = String(profile?.name || profile?.id || "功能模式");
const commandText = String(command || `进入${modeName}`).trim();
setDrawerOpen(true);
createMessage("user", commandText);
const message = createMessage("assistant", `已加载${modeName}控制指令流,正在下发。`);
setActionStatus(message, "模式控制指令正在下发", "working");
appendDispatchedControls(message, modeExecutionPreviewResult(profile));
persistMessages();
return {
message,
modeName,
commandText,
profileId: String(profile?.id || ""),
};
}
function completeModeExecution(
handle,
profile,
result = null,
{ command = "", error = null } = {},
) {
const modeName = String(handle?.modeName || profile?.name || profile?.id || "功能模式");
const commandText = String(handle?.commandText || command || `进入${modeName}`).trim();
const activeHandle = handle?.message?.body
? handle
: beginModeExecution(profile, { command: commandText });
const message = activeHandle.message;
const status = String(result?.status || "");
const dryRun = result?.execution_mode === "dry_run";
const waitingForFeedback = result?.execution_outcome === "submitted_unverified";
setDrawerOpen(true);
createMessage("user", commandText);
let content = `${modeName}执行未完成,请查看控制指令状态。`;
let statusText = "功能模式执行未完成";
@@ -1491,10 +1578,16 @@
statusText = "功能模式部分完成";
}
const message = createMessage("assistant", content);
setMessageContent(message, content, { final: true });
setActionStatus(message, statusText, statusState);
if (result) appendDispatchedControls(message, result);
persistMessages();
return activeHandle;
}
function announceModeExecution(profile, result = null, { command = "", error = null } = {}) {
const handle = beginModeExecution(profile, { command });
return completeModeExecution(handle, profile, result, { command, error });
}
function renderInitialMessages() {
@@ -1584,6 +1677,8 @@
close: () => setDrawerOpen(false),
resetConversation,
startModeCreation,
beginModeExecution,
completeModeExecution,
announceModeExecution,
announceModeCreated(profile) {
if (!profile?.id) return;
+357 -10
View File
@@ -85,6 +85,12 @@ body.console-v2 {
--glass-highlight: oklch(1 0 0 / 0.14);
--glass-shadow: 0 24px 70px oklch(0.06 0.04 285 / 0.42);
--glass-panel-shadow: 0 12px 32px oklch(0.05 0.035 285 / 0.24);
--glass-card-radius: 14px;
--glass-card-lift: -3px;
--glass-card-rest-shadow: inset 0 1px 0 var(--glass-highlight), 0 8px 22px color-mix(in oklch, var(--foreground) 10%, transparent);
--glass-card-float-shadow: inset 0 1px 0 var(--glass-highlight), 0 16px 34px color-mix(in oklch, var(--foreground) 18%, transparent);
--glass-dialog-scrim: oklch(0.035 0.025 286 / 0.82);
--glass-dialog-scrim-filter: blur(18px) saturate(0.78) brightness(0.58);
min-width: 1180px;
min-height: 620px;
overflow: hidden;
@@ -126,6 +132,8 @@ html[data-glass-theme="light"] body.console-v2 {
--sidebar-accent-foreground: var(--foreground);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
--glass-dialog-scrim: oklch(0.26 0.035 252 / 0.34);
--glass-dialog-scrim-filter: blur(14px) saturate(0.9) brightness(0.92);
--heading-foreground: oklch(0.235 0.068 255);
--body-foreground: var(--card-foreground);
--subtle-foreground: var(--muted-foreground);
@@ -438,14 +446,14 @@ body.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-s
linear-gradient(160deg, oklch(0.18 0.05 285 / 0.2), oklch(0.09 0.03 286 / 0.32)) !important;
}
body.console-v2 .joint-twin-shell :is(.twin-seat-switch, .twin-seat-switch button, .twin-reset, .twin-focus-badge) {
body.console-v2 .joint-twin-shell :is(.twin-selector, .twin-selector button, .twin-reset, .twin-focus-badge) {
color: var(--muted-foreground) !important;
background: var(--glass-card) !important;
border-color: var(--glass-line) !important;
box-shadow: inset 0 1px 0 var(--glass-highlight) !important;
}
body.console-v2 .joint-twin-shell .twin-seat-switch button.active,
body.console-v2 .joint-twin-shell .twin-selector button.active,
body.console-v2 .joint-twin-shell :is(.twin-reset, .twin-focus-badge):is(:hover, :focus-visible) {
color: var(--primary-foreground) !important;
background: color-mix(in oklch, var(--primary) 72%, transparent) !important;
@@ -588,12 +596,18 @@ body.console-v2 :is(.v2-extension-linkage, .v2-sidebar-footer) {
body.console-v2 :is(
.mode-gallery-backdrop,
.mode-editor-backdrop,
.v2-extension-dialog::backdrop,
.energy-monitor-layer
.energy-monitor-layer,
.device-power-backdrop
) {
background: oklch(0.055 0.025 286 / 0.62) !important;
-webkit-backdrop-filter: blur(14px) saturate(1.12);
backdrop-filter: blur(14px) saturate(1.12);
background: var(--glass-dialog-scrim) !important;
-webkit-backdrop-filter: var(--glass-dialog-scrim-filter) !important;
backdrop-filter: var(--glass-dialog-scrim-filter) !important;
}
body.console-v2 .v2-extension-dialog::backdrop {
background: var(--glass-dialog-scrim) !important;
-webkit-backdrop-filter: var(--glass-dialog-scrim-filter) !important;
backdrop-filter: var(--glass-dialog-scrim-filter) !important;
}
body.console-v2 :is(
@@ -631,6 +645,98 @@ body.console-v2 :is(
box-shadow: inset 0 1px 0 var(--glass-highlight), 0 9px 24px oklch(0.04 0.03 286 / 0.2) !important;
}
/* Unified floating cards -------------------------------------------------
Card geometry never changes on interaction; transform-only elevation
keeps the dashboard stable while matching the shared shadcn card states. */
body.console-v2 :is(
.v2-sync-transport-field,
.v2-sync-execution-field,
.v2-can-command-panel,
.v2-sync-log,
.v2-nfc-inline,
.v2-extension-gateway,
.v2-extension-synced-device,
.v2-extension-empty,
.v2-extension-config-card,
.v2-extension-type-card,
.v2-extension-vehicle-card,
.v2-pet-card-region,
.v2-pet-card,
.v2-pet-presence-card,
.v2-device-card,
.v2-synced-device,
.joint-seat-panel,
.domain-panel,
.coverage-card,
.decision-list li,
.frame-summary,
.execution-summary,
.mode-gallery-card,
.mode-editor-flow,
.mode-editor-command-card,
.v2-nfc-body,
.v2-nfc-existing,
.v2-nfc-pet-binding,
.energy-kpi,
.energy-trend-card,
.energy-devices-card,
.assistant-dispatched-controls,
.assistant-mode-draft,
.assistant-mode-created,
.assistant-control-suggestion
) {
border-radius: var(--glass-card-radius) !important;
box-shadow: var(--glass-card-rest-shadow) !important;
transform: translateY(0);
transition: transform 160ms ease, background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
body.console-v2 :is(
.v2-can-command-panel,
.v2-sync-log,
.v2-nfc-inline,
.v2-extension-gateway,
.v2-extension-synced-device,
.v2-extension-empty,
.v2-extension-config-card,
.v2-extension-type-card,
.v2-extension-vehicle-card,
.v2-pet-card,
.v2-pet-presence-card,
.v2-device-card,
.v2-synced-device,
.joint-seat-panel,
.domain-panel,
.coverage-card,
.decision-list li,
.frame-summary,
.execution-summary,
.mode-gallery-card,
.mode-editor-command-card,
.v2-nfc-existing,
.v2-nfc-pet-binding,
.energy-kpi,
.energy-trend-card,
.energy-devices-card,
.assistant-dispatched-controls,
.assistant-mode-draft,
.assistant-mode-created,
.assistant-control-suggestion
):is(:hover, :focus-within) {
background-color: var(--glass-card-hover) !important;
border-color: var(--glass-line-strong) !important;
box-shadow: var(--glass-card-float-shadow) !important;
transform: translateY(var(--glass-card-lift));
}
body.console-v2 :is(
.mode-gallery-card,
.assistant-control-suggestion
):active {
box-shadow: var(--glass-card-rest-shadow) !important;
transform: translateY(0);
}
body.console-v2 :is(.mode-gallery-close, .mode-editor-header > button, .v2-extension-dialog [data-dialog-close], #energyMonitorClose) {
color: var(--muted-foreground) !important;
background: var(--glass-card) !important;
@@ -644,12 +750,12 @@ body.console-v2 :is(.mode-gallery-close, .mode-editor-header > button, .v2-exten
/* Persistent day / night appearance ------------------------------------- */
.glass-theme-toggle {
width: 48px;
height: 48px;
width: 44px;
height: 44px;
position: fixed;
left: max(16px, env(safe-area-inset-left));
bottom: max(16px, env(safe-area-inset-bottom));
z-index: 1600;
z-index: 1100;
display: grid;
place-items: center;
padding: 0;
@@ -776,6 +882,206 @@ html[data-glass-theme="light"] body.console-v2 :is(
box-shadow: 0 28px 84px color-mix(in oklch, var(--foreground) 20%, transparent), inset 0 1px 0 var(--glass-highlight) !important;
}
/* Audited component hierarchy -------------------------------------------
Keep shadcn-style roles visually distinct: panel > section > item >
control. These rules intentionally live after the theme variants so both
day and night modes consume the same geometry and typography contract. */
body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync :is(
.main-panel > .v2-mode-rail,
#assistantDrawer,
.joint-twin-shell > .twin-band,
#v2DevicePanel,
#v2ExtensionPanel .v2-extension-sync-mount
) {
border-radius: var(--ui-radius-panel) !important;
}
body.console-v2 :is(
.v2-sync-log,
.mode-gallery-group,
.v2-pet-card-region,
.energy-trend-card,
.energy-devices-card
) {
border-radius: var(--ui-radius-section) !important;
}
body.console-v2 :is(
.v2-sync-transport-field,
.v2-sync-execution-field,
.v2-can-command-panel,
.v2-nfc-inline,
.v2-extension-gateway,
.v2-extension-synced-device,
.v2-extension-empty,
.v2-extension-config-card,
.v2-extension-type-card,
.v2-extension-vehicle-card,
.v2-pet-card,
.v2-pet-presence-card,
.v2-device-card,
.v2-synced-device,
.mode-gallery-card,
.mode-editor-flow,
.mode-editor-command-card,
.energy-kpi,
.assistant-dispatched-controls,
.assistant-mode-draft,
.assistant-mode-created,
.assistant-control-suggestion
) {
border-radius: var(--ui-radius-item) !important;
}
body.console-v2 :is(
.mode-gallery,
.mode-editor,
.v2-nfc-dialog,
.v2-gscu-tcp-dialog,
.v2-pet-settings-dialog,
.energy-monitor-page
) {
border-radius: var(--ui-radius-modal) !important;
}
/* Read-only containers must not imitate clickable cards on hover. */
body.console-v2 :is(
.v2-sync-transport-field,
.v2-sync-execution-field,
.v2-sync-log,
.v2-pet-card-region,
.mode-editor-flow,
.energy-trend-card,
.energy-devices-card
):is(:hover, :focus-within) {
background-color: var(--glass-card) !important;
border-color: var(--glass-line) !important;
box-shadow: var(--glass-card-rest-shadow) !important;
transform: none;
}
/* One title scale across peer panels and dialogs. */
body.console-v2 :is(
#assistantDrawer .v2-operation-heading h2,
.joint-twin-shell .twin-heading h3,
.v2-device-sync-board > header h3,
.v2-extension-sync-heading h3
) {
font-size: var(--ui-font-panel-title) !important;
font-weight: 700 !important;
line-height: 1.25 !important;
}
body.console-v2 :is(.v2-sync-log h3, .v2-mode-rail .mode-rail-title) {
font-size: 14px !important;
font-weight: 700 !important;
line-height: 1.25 !important;
}
body.console-v2 :is(
.mode-gallery-header h2,
.mode-editor-header h2,
.v2-extension-dialog h2,
.energy-monitor-heading h2
) {
font-size: 22px !important;
font-weight: 700 !important;
line-height: 1.2 !important;
letter-spacing: -0.025em !important;
}
body.console-v2 :is(
.mode-gallery-header p,
.mode-gallery-footer,
.mode-editor-header p,
.mode-editor-status,
.v2-pet-settings-footnote,
.v2-nfc-footnote
) {
font-size: 12px !important;
line-height: 1.5 !important;
}
body.console-v2 :is(
.mode-gallery-card-heading strong,
.v2-pet-card > header > strong,
.v2-gscu-actions > header strong,
.v2-gscu-command-catalog > header strong
) {
font-size: var(--ui-font-card-title) !important;
line-height: 1.35 !important;
}
body.console-v2 :is(
.mode-gallery-card-description,
.v2-pet-card-fields > label > span,
.v2-gscu-tcp-dialog label,
.energy-kpi > small
) {
font-size: 12px !important;
line-height: 1.4 !important;
}
body.console-v2 :is(.mode-gallery-card-meta, .energy-kpi > em, .energy-devices-card > footer) {
font-size: 11px !important;
line-height: 1.4 !important;
}
/* Consistent icon-button variants. */
body.console-v2 :is(
.mode-gallery-close,
.mode-editor-header > button,
.v2-extension-dialog [data-dialog-close],
.v2-extension-dialog [data-close-extension-dialog],
.v2-extension-dialog [data-close-nfc-dialog],
#energyMonitorClose,
.twin-reset
) {
width: var(--ui-control-size-icon) !important;
height: var(--ui-control-size-icon) !important;
min-width: var(--ui-control-size-icon) !important;
min-height: var(--ui-control-size-icon) !important;
border-radius: var(--ui-radius-control) !important;
}
body.console-v2 :is(.mode-gallery-edit, .mode-gallery-delete, .v2-pet-card > header > button) {
width: var(--ui-control-height-compact) !important;
height: var(--ui-control-height-compact) !important;
min-width: var(--ui-control-height-compact) !important;
min-height: var(--ui-control-height-compact) !important;
border-radius: var(--ui-radius-control-inner) !important;
}
/* Make horizontal continuation discoverable without adding another control. */
body.console-v2 .v2-mode-rail .mode-chips {
scroll-padding-inline: 18px;
-webkit-mask-image: linear-gradient(90deg, transparent, black 18px, black calc(100% - 26px), transparent);
mask-image: linear-gradient(90deg, transparent, black 18px, black calc(100% - 26px), transparent);
}
/* Reserve scroll space beneath the required floating theme switch. */
body.console-v2 .v2-sync-log ol {
padding-bottom: 58px !important;
scroll-padding-bottom: 58px;
}
/* Offline is a destructive state, not an active primary state. */
body.console-v2 .v2-nfc-stage[data-state="offline"] > header {
color: var(--status-error-foreground) !important;
background: color-mix(in oklch, var(--destructive) 16%, var(--card)) !important;
border-bottom: 1px solid color-mix(in oklch, var(--destructive) 30%, var(--border));
}
body.console-v2 .v2-nfc-stage[data-state="offline"] > header :is(span, small, h3, p, b) {
color: var(--status-error-foreground) !important;
}
body.console-v2 .v2-nfc-stage[data-state="offline"] > header > span,
body.console-v2 .v2-nfc-stage[data-state="offline"] > header > b {
background: var(--status-error-soft) !important;
border-color: color-mix(in oklch, var(--destructive) 28%, var(--border)) !important;
}
/* Scrollbars ------------------------------------------------------------- */
body.console-v2 * {
scrollbar-color: color-mix(in oklch, var(--primary) 70%, white 8%) color-mix(in oklch, var(--background) 66%, transparent);
@@ -807,4 +1113,45 @@ body.console-v2 *::-webkit-scrollbar-thumb {
body.console-v2::after {
filter: blur(44px);
}
body.console-v2 :is(
.v2-sync-transport-field,
.v2-sync-execution-field,
.v2-can-command-panel,
.v2-sync-log,
.v2-nfc-inline,
.v2-extension-gateway,
.v2-extension-synced-device,
.v2-extension-empty,
.v2-extension-config-card,
.v2-extension-type-card,
.v2-extension-vehicle-card,
.v2-pet-card-region,
.v2-pet-card,
.v2-pet-presence-card,
.v2-device-card,
.v2-synced-device,
.joint-seat-panel,
.domain-panel,
.coverage-card,
.decision-list li,
.frame-summary,
.execution-summary,
.mode-gallery-card,
.mode-editor-flow,
.mode-editor-command-card,
.v2-nfc-body,
.v2-nfc-existing,
.v2-nfc-pet-binding,
.energy-kpi,
.energy-trend-card,
.energy-devices-card,
.assistant-dispatched-controls,
.assistant-mode-draft,
.assistant-mode-created,
.assistant-control-suggestion
) {
transform: none !important;
transition: none !important;
}
}
+261 -17
View File
@@ -266,7 +266,7 @@
if (isPassengerSeat) {
const passengerAxes = {
"seat.set_longitudinal_position": ["副驾座椅前后调节", "horizontal", 0],
"seat.set_cushion_tilt": ["副驾座椅高度调节", "basin", 1],
"seat.set_cushion_tilt": ["副驾座椅坐盆调节", "basin", 1],
"seat.set_backrest_recline": ["副驾座椅靠背调节", "backrest", 2],
"seat.set_legrest_height": ["副驾座椅腿托调节", "legrest", 3],
};
@@ -447,11 +447,19 @@
height: new Set(["up", "down", "stop"]),
backrest: new Set(["forward", "backward", "stop"]),
basin: new Set(["up", "down", "stop"]),
legrest: new Set(["up", "down", "forward", "backward", "stop"]),
};
if (validDirections[axis]?.has(direction)) {
if (direction === "stop") return command("主驾座椅调节", `seat driver ${axis} stop`);
const axisCategory = {
horizontal: "主驾座椅前后调节",
height: "主驾座椅坐盆高度调节",
basin: "主驾座椅坐盆调节",
backrest: "主驾座椅靠背调节",
legrest: "主驾座椅腿托调节",
}[axis] || "主驾座椅调节";
if (direction === "stop") return command(axisCategory, `seat driver ${axis} stop`);
const duration = boundedInteger(parameters.duration_ms, 100, 2000);
if (duration !== null) return command("主驾座椅调节", `seat driver ${axis} ${direction} ${duration}`);
if (duration !== null) return command(axisCategory, `seat driver ${axis} ${direction} ${duration}`);
}
}
if (capability === "seat.set_driver_position_native") {
@@ -531,6 +539,7 @@
}
});
const submittedStates = new Set(["submitted", "queued", "published", "sent", "delivered"]);
const abortedStates = new Set(["aborted", "cancelled", "canceled", "skipped"]);
const deliveryForOperation = (operation) => {
const capability = String(operation?.capability || "");
const operationId = String(operation?.id || "");
@@ -555,30 +564,59 @@
|| telemetry.feedback_observed === true
|| telemetry.physical_effect_confirmed === true
);
const executionStatus = String(execution?.status || "").toLowerCase();
const verificationStatus = String(telemetry.verification_status || "").toLowerCase();
const failureMessage = String(execution?.message || "").trim();
const executionFailed = execution?.status === "failed"
|| telemetry.verification_status === "failed_or_blocked";
const safetyBlocked = executionFailed && !submitted && (
/no fresh valid 0x41a shade position/i.test(failureMessage)
|| /refusing (?:blind )?motion/i.test(failureMessage)
|| /thermal protection/i.test(failureMessage)
const aborted = abortedStates.has(executionStatus);
const executionFailed = executionStatus === "failed"
|| verificationStatus === "failed_or_blocked";
const missingWindowFeedback = /0x388/i.test(failureMessage) && (
/no fresh valid/i.test(failureMessage)
|| /未收到.*(?:有效|新鲜).*车窗位置/.test(failureMessage)
);
const deliveryState = safetyBlocked
const missingShadeFeedback = /0x41a/i.test(failureMessage) && (
/no fresh valid/i.test(failureMessage)
|| /未收到.*(?:有效|新鲜).*遮阳帘.*位置/.test(failureMessage)
);
const safetyBlocked = executionFailed && !submitted && (
missingWindowFeedback
|| missingShadeFeedback
|| /refusing (?:blind )?motion/i.test(failureMessage)
|| /拒绝(?:启动机构|盲动|执行)/.test(failureMessage)
|| /thermal protection|热保护/i.test(failureMessage)
);
const dispatchFailed = executionFailed && !submitted && !safetyBlocked;
const verified = !executionFailed && !aborted && (
verificationStatus === "verified_success"
|| telemetry.physical_effect_confirmed === true
|| telemetry.feedback_match === true
);
const deliveryState = aborted
? "aborted"
: safetyBlocked
? "blocked"
: executionFailed
? "failed"
: submitted
? "submitted"
: "not_submitted";
const deliveryLabel = safetyBlocked
const deliveryLabel = aborted
? "已中止"
: safetyBlocked
? "安全阻止"
: executionFailed
: dispatchFailed
? "下发失败"
: executionFailed
? "执行失败"
: verified
? "执行已确认"
: submitted
? "下发"
? "已下发 · 待状态确认"
: "未下发";
const deliveryDetail = /no fresh valid 0x41a shade position/i.test(failureMessage)
? "未收到遮阳帘 CAN1 / 0x41A 实时位置,无法判断移动方向,系统已阻止盲动"
const deliveryDetail = missingWindowFeedback
? "未收到车窗 CAN1 / 0x388 实时位置,系统已阻止车窗盲动"
: missingShadeFeedback
? "未收到遮阳帘 CAN1 / 0x41A 实时位置,无法判断移动方向,系统已阻止盲动"
: failureMessage;
const transportLabel = capability === "infrared.emit"
? "红外 TCP"
@@ -614,18 +652,32 @@
.filter((frame) => frame.value);
const command = String(presentation?.actual_command || fallback.command || "").trim();
if (!command) return;
const delivery = deliveryForOperation(operation);
const explicitDeliveryState = String(presentation?.delivery_state || "").trim();
const explicitDeliveryLabel = String(presentation?.delivery_label || "").trim();
const explicitDeliveryDetail = String(presentation?.delivery_detail || "").trim();
const explicitDeliveryTransport = String(presentation?.delivery_transport || "").trim();
commands.push({
...fallback,
channel,
command,
category: String(presentation?.category || fallback.category || "控制指令"),
...(frames.length ? {
frame: frames[0].value,
frameLabel: frames[0].label,
frames,
} : {}),
semantic: semanticControlInstruction(operation),
semantic: String(
presentation?.semantic_instruction || semanticControlInstruction(operation),
),
operationRef: String(operation.id || ""),
...deliveryForOperation(operation),
...delivery,
...(explicitDeliveryState ? { deliveryState: explicitDeliveryState } : {}),
...(explicitDeliveryLabel ? { deliveryLabel: explicitDeliveryLabel } : {}),
...(explicitDeliveryDetail ? { deliveryDetail: explicitDeliveryDetail } : {}),
...(explicitDeliveryTransport ? {
deliveryTransport: explicitDeliveryTransport,
} : {}),
});
});
} else {
@@ -669,6 +721,7 @@
saved_mode_command_flow: "模式卡片",
local_rule: "本地规则",
speech_semantic: "语音语义",
vehicle_mqtt_extension: "车端 MQTT",
}[source] || "云端模型";
}
@@ -874,6 +927,10 @@
<b class="v2-can-command-delivery" data-delivery-state="${escapeHtml(item.deliveryState)}" title="${escapeHtml([item.deliveryTransport, item.deliveryLabel, item.deliveryDetail].filter(Boolean).join(" · "))}">${escapeHtml(item.deliveryLabel)}</b>
<i class="v2-can-command-category">${escapeHtml(item.category)}</i>
</span>
${item.deliveryDetail && ["blocked", "failed", "aborted"].includes(item.deliveryState) ? `
<small class="v2-can-command-delivery-detail">
<b>状态说明</b>${escapeHtml(item.deliveryDetail)}
</small>` : ""}
<div class="v2-can-command-layer v2-can-command-semantic">
<small>语义控制指令</small>
<code>${escapeHtml(item.semantic)}</code>
@@ -1214,6 +1271,13 @@
logKeys: new Set(),
extensionFingerprint: "",
};
const extensionControlSync = {
revision: -1,
requestId: "",
running: false,
stopped: true,
controller: null,
};
const nonDispatchTaskSources = new Set(["mode_editor_compile"]);
function isDispatchTask(task = {}) {
@@ -2074,6 +2138,183 @@
}
}
function extensionControlDelivery(action = {}) {
const state = String(action.state || "pending");
const defaults = {
pending: ["not_submitted", "等待下发"],
executing: ["executing", "正在下发"],
submitted_unverified: ["submitted", "已下发 · 物理效果未确认"],
verified_success: ["submitted", "执行已确认"],
failed: ["failed", "下发失败"],
aborted: ["aborted", "已中止"],
};
const [deliveryState, deliveryLabel] = defaults[state] || defaults.pending;
return {
deliveryState,
deliveryLabel: String(action.state_label || deliveryLabel),
deliveryDetail: String(action.state_detail || ""),
};
}
function extensionControlResult(snapshot = {}) {
const requestId = String(snapshot.last_request_id || "");
const actions = Array.isArray(snapshot.action_statuses)
? snapshot.action_statuses
: [];
return {
request_id: requestId,
utterance: "车端 MQTT 外设控制",
status: snapshot.state === "failed" ? "failed" : "succeeded",
execution_mode: "hardware",
control_transport: "extension_mqtt",
orchestration_route: "vehicle_mqtt_extension_control",
validated_control_commands: actions.map((action, position) => {
const index = Number.isInteger(Number(action.index))
? Number(action.index)
: position;
const atMs = Math.max(0, Number(action.at_ms) || 0);
const slot = Number(action.slot);
const extensionName = String(action.extension_name || "红外外设");
const actionName = String(action.action || "红外动作");
const targetScope = String(action.target_scope || "external-device");
const delivery = extensionControlDelivery(action);
return {
domain: "self_extension",
channel: "IR",
category: `${extensionName} · target_scope=${targetScope} · at_ms=${atMs}`,
semantic_instruction: `${extensionName} · ${actionName}`,
actual_command: `infrared emit ${slot}`,
delivery_state: delivery.deliveryState,
delivery_label: delivery.deliveryLabel,
delivery_detail: [delivery.deliveryDetail, `at_ms=${atMs}`]
.filter(Boolean)
.join(" · "),
delivery_transport: "车端 MQTT → 红外 TCP",
frames: [{
label: "GSCU1B TCP",
value: JSON.stringify({ action: "emit", data: { no: slot }, type: "infrared" }),
}],
operation: {
id: `${requestId || "extension-ctl"}:${index}`,
domain: "self_extension",
capability: "infrared.emit",
target: { device: targetScope, name: extensionName },
parameters: { action: actionName, slot },
parse_source: "vehicle_mqtt_extension",
},
};
}),
};
}
function renderExtensionControlRejection(snapshot = {}) {
const panel = $("#v2CanCommandPanel");
const list = $("#v2CanCommandList");
const count = $("#v2CanCommandCount");
if (!panel || !list || !count) return;
count.textContent = "0";
panel.dataset.state = "error";
panel.open = true;
list.innerHTML = `<p>车端 MQTT 外设控制已拒绝:${escapeHtml(snapshot.last_error || "消息未通过校验")}</p>`;
if (ui.sidebarCollapsed) setSidebarCollapsed(false);
requestAnimationFrame(syncSidebarLogBaseline);
}
function applyExtensionControlSnapshot(snapshot = {}) {
const revision = Number(snapshot.revision);
if (!Number.isInteger(revision)) return;
if (revision < extensionControlSync.revision) {
extensionControlSync.revision = -1;
extensionControlSync.requestId = "";
}
if (revision <= extensionControlSync.revision) return;
extensionControlSync.revision = revision;
const requestId = String(snapshot.last_request_id || "");
const state = String(snapshot.state || "");
const actions = Array.isArray(snapshot.action_statuses)
? snapshot.action_statuses
: [];
if (requestId) extensionControlSync.requestId = requestId;
if (actions.length) {
renderParsedCanCommands(extensionControlResult(snapshot), { reveal: true });
appendSyncLog({
key: `extension-control:${requestId}:${state}:${snapshot.executed_action_count || 0}`,
category: "extension",
title: state === "completed"
? "车端 MQTT 外设控制已下发"
: state === "failed"
? "车端 MQTT 外设控制下发失败"
: "正在执行车端 MQTT 外设控制",
detail: `${actions.length} 条红外指令 · ${snapshot.last_error || state}`,
status: state === "failed" ? "error" : state === "completed" ? "success" : "info",
});
return;
}
if (requestId && ["rejected", "queue_full"].includes(state)) {
renderExtensionControlRejection(snapshot);
appendSyncLog({
key: `extension-control:${requestId}:${state}`,
category: "extension",
title: "车端 MQTT 外设控制已拒绝",
detail: String(snapshot.last_error || "消息未通过校验"),
status: "error",
});
}
}
const extensionControlRetryDelay = () => new Promise((resolve) => {
window.setTimeout(resolve, 1000);
});
async function monitorExtensionControl() {
if (extensionControlSync.running) return;
extensionControlSync.running = true;
extensionControlSync.stopped = false;
try {
while (!extensionControlSync.stopped) {
const controller = new AbortController();
extensionControlSync.controller = controller;
const query = new URLSearchParams({
after_revision: String(extensionControlSync.revision),
timeout_ms: "25000",
});
try {
const response = await fetch(`/api/extension/control/status?${query}`, {
credentials: "same-origin",
cache: "no-store",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
applyExtensionControlSnapshot(await response.json());
} catch (error) {
if (extensionControlSync.stopped || error?.name === "AbortError") break;
console.warn("[extension-control-sync]", error);
await extensionControlRetryDelay();
} finally {
if (extensionControlSync.controller === controller) {
extensionControlSync.controller = null;
}
}
}
} finally {
extensionControlSync.running = false;
}
}
function startExtensionControlMonitor() {
if (extensionControlSync.running) return;
extensionControlSync.stopped = false;
void monitorExtensionControl();
}
function stopExtensionControlMonitor() {
extensionControlSync.stopped = true;
extensionControlSync.controller?.abort();
extensionControlSync.controller = null;
}
function applyDisplaySnapshot(snapshot) {
const synchronizedCatalog = Array.isArray(snapshot?.extension?.catalog)
? snapshot.extension.catalog
@@ -2461,6 +2702,9 @@
appendSyncLog({ key: "client:ready", category: "sync", title: "同步控制台已就绪", detail: "正在连接车机任务与设备状态服务", status: "info" });
pollVehicleDisplaySync();
displaySync.pollTimer = window.setInterval(pollVehicleDisplaySync, 2000);
startExtensionControlMonitor();
window.addEventListener("pagehide", stopExtensionControlMonitor);
window.addEventListener("pageshow", startExtensionControlMonitor);
const updateClock = () => {
if ($("#v2SyncClock")) $("#v2SyncClock").textContent = new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
+1 -1
View File
@@ -566,7 +566,7 @@ async function loadGestureRuntime() {
async function loadRecognizer() {
if (state.recognizer) return;
$("#gestureSafetyText").textContent = "正在加载手势模型…";
const { FilesetResolver, GestureRecognizer } = await import(VISION_MODULE);
const { FilesetResolver, GestureRecognizer } = await import(/* @vite-ignore */ VISION_MODULE);
const fileset = await FilesetResolver.forVisionTasks(VISION_WASM);
const modelResponse = await window.CognitiveAgentNetwork.request("/api/gesture/model", {
cache: "force-cache",
+27 -612
View File
@@ -11,630 +11,45 @@
<title>511工程-智能座舱</title>
<script>
(() => {
const key = "cognitive-agent.glass-theme";
let theme = "dark";
try {
const stored = localStorage.getItem(key);
if (stored === "light" || stored === "dark") theme = stored;
} catch (_error) {
// Storage can be unavailable in hardened or private browser contexts.
}
try {
const cookieTheme = document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${key}=`))
?.slice(key.length + 1);
if (cookieTheme === "light" || cookieTheme === "dark") theme = cookieTheme;
} catch (_error) {
// The default still applies when cookies are disabled.
}
document.documentElement.dataset.glassTheme = theme;
document.documentElement.style.colorScheme = theme;
})();
const key = "cognitive-agent.glass-theme";
let theme = "dark";
try {
const stored = localStorage.getItem(key);
if (stored === "light" || stored === "dark") theme = stored;
} catch (_error) {
// Storage can be unavailable in hardened or private browser contexts.
}
try {
const cookieTheme = document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${key}=`))
?.slice(key.length + 1);
if (cookieTheme === "light" || cookieTheme === "dark") theme = cookieTheme;
} catch (_error) {
// The default still applies when cookies are disabled.
}
document.documentElement.dataset.glassTheme = theme;
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/styles.css?v=dual-seat-23" />
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-69" />
<link rel="stylesheet" href="/self-extension.css?v=pet-settings-52" />
<link rel="stylesheet" href="/speech-input.css?v=sensevoice-7" />
<link rel="stylesheet" href="/assistant-chat.css?v=codex-assistant-29" />
<link rel="stylesheet" href="/energy-monitor.css?v=energy-monitor-4" />
<link rel="stylesheet" href="/ui-consistency.css?v=spatial-ui-5" />
<link rel="stylesheet" href="/codepen-glass-theme.css?v=creative-cloud-2" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/vue/3.5.21/vue.runtime.global.prod.js" defer></script>
<script src="/vue-ui.js?v=vue-presentation-3" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-2" defer></script>
<script src="/app.js?v=dual-seat-140" defer></script>
<script src="/app.js?v=dual-seat-141" defer></script>
<script src="/speech-input.js?v=sensevoice-13" defer></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="/console-v2.js?v=local-first-70" defer></script>
<script src="/console-v2.js?v=local-first-76" defer></script>
<script src="/self-extension.js?v=pet-settings-52" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-33" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-34" defer></script>
<script src="/energy-monitor.js?v=energy-monitor-4" defer></script>
</head>
<body class="console-v2">
<div class="v2-boot-screen" id="v2BootScreen" role="status" aria-live="polite">
<span class="v2-boot-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<strong>511工程-智能座舱</strong>
<small>正在加载车机控制与实时状态</small>
</div>
<div class="workspace">
<aside class="command-panel">
<div class="panel-heading">
<h2>说一句,就调好了</h2>
</div>
<div class="mode-rail" aria-label="功能模式">
<div class="mode-rail-bar">
<div class="mode-rail-identity">
<span class="mode-rail-mark" aria-hidden="true"><i data-lucide="sparkles"></i></span>
<span><strong class="mode-rail-title">功能模式</strong><small>预设与我的模式</small></span>
</div>
<div class="mode-rail-content" id="modeRailContent" role="region" aria-label="功能模式列表">
<div class="mode-chips" tabindex="0" aria-label="快捷功能模式,可横向滚动" aria-live="polite">
<button type="button" data-mode-profile-id="zero_gravity" data-mode-origin="preset" data-mode-command="进入零重力模式"><i data-lucide="orbit"></i><span>零重力</span></button>
<button type="button" data-mode-profile-id="office" data-mode-origin="preset" data-mode-command="进入办公模式"><i data-lucide="briefcase"></i><span>办公</span></button>
<button type="button" data-mode-profile-id="nap" data-mode-origin="preset" data-mode-command="进入小憩模式"><i data-lucide="moon-star"></i><span>小憩</span></button>
</div>
</div>
<div class="mode-rail-actions">
<button class="mode-gallery-toggle" id="modeGalleryToggle" type="button" aria-expanded="false" aria-controls="modeGallery"><i data-lucide="settings-2" data-icon="inline-start"></i><span>管理</span><strong id="modeGalleryCount">0</strong></button>
<button class="mode-create-toggle" id="saveModeToggle" type="button"><i data-lucide="sparkles" data-icon="inline-start"></i><span>AI 新建</span></button>
<button class="mode-rail-toggle" id="modeRailToggle" type="button" aria-expanded="true" aria-controls="modeRailContent" aria-label="收起功能模式" title="收起功能模式">
<span class="visually-hidden" id="modeRailSelection">静态模式</span><i data-lucide="chevron-up"></i>
</button>
</div>
</div>
</div>
<form id="requestForm" autocomplete="off">
<section class="assistant-transcript" id="assistantTranscript" role="log" aria-live="polite" aria-label="AI助手对话">
<div class="assistant-messages" id="assistantMessages">
<article class="assistant-message assistant-message-assistant">
<span class="assistant-avatar" aria-hidden="true"><i data-lucide="waypoints"></i></span>
<div class="assistant-message-body">
<div class="assistant-message-content">你好,我是 AI助手。你可以向我提问,也可以直接交代需要完成的工作。</div>
</div>
</article>
</div>
</section>
<div class="field-label-row">
<label for="utterance">向 AI助手提问或交代工作</label>
<button class="context-reset" id="newConversation" type="button" title="开始新会话" aria-label="开始新会话">
<i data-lucide="message-square-plus"></i>
</button>
</div>
<div class="command-composer">
<textarea id="utterance" name="utterance" maxlength="4000" autocomplete="off" placeholder="向 AI助手提问或交代工作"></textarea>
<button class="speech-dictation" id="speechDictationButton" type="button" aria-label="开始语音输入" aria-pressed="false" title="开始语音输入">
<i class="speech-mic-icon" data-lucide="mic" aria-hidden="true"></i>
<span class="speech-waveform" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span>
<span class="speech-visually-hidden">开始语音输入</span>
</button>
<span class="speech-status" id="speechDictationStatus" role="status" aria-live="polite"></span>
<span class="composer-hint">Enter 发送 · Shift + Enter 换行</span>
</div>
<div class="field-group">
<span class="field-label">意图解析器</span>
<div class="segmented" role="group" aria-label="意图解析器">
<button type="button" class="segment active" data-provider="openai">
<i data-lucide="sparkles"></i>GPT
</button>
<button type="button" class="segment" data-provider="local">
<i data-lucide="braces"></i>本地规则
</button>
</div>
<p class="provider-note" id="providerNote">正在读取运行时配置</p>
</div>
<div class="field-group" id="reasoningField">
<span class="field-label">模型推理强度</span>
<div class="segmented reasoning-segmented" role="group" aria-label="模型推理强度">
<button type="button" class="segment reasoning-option" data-reasoning-effort="low">
<i data-lucide="zap"></i><span><strong>极速</strong><small>简单指令,优先响应速度</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
<button type="button" class="segment reasoning-option active" data-reasoning-effort="medium">
<i data-lucide="scale"></i><span><strong>均衡</strong><small>日常控制,兼顾速度与理解</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
<button type="button" class="segment reasoning-option" data-reasoning-effort="high">
<i data-lucide="brain"></i><span><strong>深度</strong><small>复杂联动、相对动作与约束</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
</div>
<p class="provider-note" id="reasoningNote">均衡模式兼顾自然语言解析精度与响应速度</p>
</div>
<div class="field-group">
<span class="field-label">执行目标</span>
<div class="segmented execution-mode-options" role="group" aria-label="执行目标">
<button type="button" class="mode-option" data-execution-mode="dry_run">
<i data-lucide="flask-conical"></i>模拟下发
</button>
<button type="button" class="mode-option active" id="hardwareMode" data-execution-mode="hardware">
<i data-lucide="radio-tower"></i>真实下发
</button>
</div>
</div>
<div class="field-group" id="vehicleTransportField">
<span class="field-label">车辆连接方式</span>
<div class="segmented vehicle-transport-options" role="group" aria-label="车辆连接方式">
<button type="button" class="mode-option active" data-control-transport="wired_can">
<i data-lucide="cable"></i>有线 CAN
</button>
<button type="button" class="mode-option" data-control-transport="wireless_mqtt">
<i data-lucide="wifi"></i>无线 CAN
</button>
</div>
</div>
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
<label class="field-label" for="simulationFault">故障注入场景</label>
<select id="simulationFault" aria-label="选择模拟硬件故障">
<option value="normal">正常反馈</option>
<option value="feedback_timeout">反馈超时</option>
<option value="adapter_disconnect">适配器断开</option>
<option value="motor_stall">电机堵转</option>
<option value="firmware_mismatch">固件不匹配</option>
<option value="partial_feedback">部分反馈</option>
</select>
<p class="provider-note">仅生成模拟 HIL 结果,不会打开物理 CAN 适配器或调用 IAR。</p>
</div>
<div class="field-group vehicle-field">
<label class="field-label" for="vehicleSelect">车型配置</label>
<div class="vehicle-select-control">
<i data-lucide="car-front"></i>
<select id="vehicleSelect" name="vehicle_id" aria-label="选择车型配置">
<option value="">加载车型配置</option>
</select>
</div>
<p class="provider-note" id="vehicleNote">正在读取座椅量程与角度标定</p>
</div>
<button class="primary-action" id="runButton" type="submit" aria-label="发送消息">
<i data-lucide="send-horizontal"></i>
<span>发送</span>
</button>
</form>
</aside>
<main class="main-panel">
<section class="request-header">
<div>
<div class="request-title-row">
<h2 id="resultTitle">等待请求</h2>
<span class="run-status idle" id="runStatus">IDLE</span>
</div>
<p id="requestMeta">Cabin / reference_vehicle / dry-run</p>
</div>
<div class="matrix-stat">
<i data-lucide="table-properties"></i>
<div><strong id="matrixCount">10 / 57</strong><span>报文 / 信号</span></div>
</div>
</section>
<section class="cabin-band" aria-labelledby="cabinStateTitle">
<div class="section-title-row cabin-heading">
<div class="cabin-title-copy">
<h3 id="cabinStateTitle">整车设备状态</h3>
</div>
<div class="cabin-channel-legend" aria-label="整车设备通道说明">
<span data-channel="ch0"><i>CH0</i>副驾座椅</span>
<span data-channel="ch1"><i>CH1</i>主驾 / 车身</span>
<span class="simulation-label" id="feedbackModeLabel">MATRIX-BACKED · SIMULATED FEEDBACK</span>
</div>
</div>
<div class="domain-grid">
<article class="domain-panel climate-panel" data-domain-panel="climate" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="fan"></i></span>
<div><strong>空调</strong><small>CMD 0x1DE · FB 0x404 · CH1</small></div>
<span class="device-state" id="climatePower">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>电源 · 温度 · 风量/风向</p>
<div class="climate-display">
<div class="zone-temperature">
<span>主驾</span><strong id="driverTemperature">--<sup>℃</sup></strong>
<small>风向<b id="driverAirDirection">-</b></small>
</div>
<div class="airflow-visual" id="airflowVisual" data-level="0" aria-label="空调风量">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</div>
<div class="zone-temperature passenger-temperature">
<span>副驾</span><strong id="passengerTemperature">--<sup>℃</sup></strong>
<small>风向<b id="passengerAirDirection">-</b></small>
</div>
</div>
<dl class="domain-readouts">
<div><dt>风量</dt><dd id="fanLevel">未知</dd></div>
<div><dt>循环</dt><dd id="cycleMode">未知</dd></div>
<div><dt>A/C</dt><dd id="acState">未知</dd></div>
<div><dt>前除霜</dt><dd id="defrostState">未知</dd></div>
</dl>
</article>
<article class="domain-panel vehicle-status-panel" data-domain-panel="vehicle_status" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="gauge"></i></span>
<div><strong>车辆状态</strong><small>FB 0x342 · 0x4EB · 0x407 · CH1</small></div>
<span class="device-state readonly-state" id="vehicleTelemetryState">只读</span>
</header>
<p class="domain-capability-line readonly-capability"><span>只读信号</span>档位 · 车速 · 四门锁状态</p>
<div class="vehicle-primary-readout">
<div><span>当前档位</span><strong id="vehicleGear">--</strong></div>
<div><span>实时车速</span><strong id="vehicleSpeed">-- km/h</strong></div>
</div>
<div class="door-state-grid" aria-label="四门锁状态">
<span data-door-state="front_left"><i></i>左前门<b id="frontLeftDoorState">未知</b></span>
<span data-door-state="front_right"><i></i>右前门<b id="frontRightDoorState">未知</b></span>
<span data-door-state="rear_left"><i></i>左后门<b id="rearLeftDoorState">未知</b></span>
<span data-door-state="rear_right"><i></i>右后门<b id="rearRightDoorState">未知</b></span>
</div>
</article>
<article class="domain-panel roof-panel" data-domain-panel="roof" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="panels-top-left"></i></span>
<div><strong>遮阳帘</strong><small>CMD 0x431 · FB 0x41A · CH1</small></div>
<span class="device-state" id="roofState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>开关 / 停止 · 位置</p>
<div class="domain-summary-visual shade-summary-visual" aria-hidden="true">
<span>闭合</span><i><b id="shadePositionMeter"></b></i><span>打开</span>
</div>
<dl class="domain-readouts">
<div><dt>遮阳帘</dt><dd id="shadePositionValue">未知</dd></div>
<div><dt>动作</dt><dd id="shadeMotionState">未知</dd></div>
<div><dt>初始化</dt><dd id="shadeInitializedState">未知</dd></div>
<div><dt>热保护</dt><dd id="shadeThermalState">未知</dd></div>
</dl>
</article>
<article class="domain-panel ambient-panel" data-domain-panel="ambient_light" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="lamp-ceiling"></i></span>
<div><strong>车内灯光</strong><small>INTERIOR / AMBIENT · CH1</small></div>
<span class="device-state" id="ambientPower">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>阅读灯 · 氛围亮度 · 色号</p>
<div class="domain-summary-visual ambient-summary-visual" aria-hidden="true">
<span data-ambient-zone="front"><i id="ambientFrontMeter"></i><b>前排</b></span>
<span data-ambient-zone="rear"><i id="ambientRearMeter"></i><b>后排</b></span>
</div>
<dl class="domain-readouts">
<div><dt>氛围灯</dt><dd id="ambientMode">未知</dd></div>
<div><dt>室内/阅读灯</dt><dd id="interiorLightPower">未知</dd></div>
<div><dt>氛围亮度</dt><dd id="ambientBrightness">- / -</dd></div>
<div><dt>氛围色号</dt><dd id="ambientColorIndex">- / -</dd></div>
</dl>
</article>
<article class="domain-panel window-panel" data-domain-panel="window" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="gallery-vertical-end"></i></span>
<div><strong>车窗</strong><small>CMD 0x431 · FB 0x388 · CH1</small></div>
<span class="device-state" id="windowState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>四窗独立 · 开/关/停 · 位置</p>
<div class="domain-summary-visual window-summary-visual" aria-hidden="true">
<span data-window-meter="front_left"><i></i><b>左前</b></span>
<span data-window-meter="front_right"><i></i><b>右前</b></span>
<span data-window-meter="rear_left"><i></i><b>左后</b></span>
<span data-window-meter="rear_right"><i></i><b>右后</b></span>
</div>
<dl class="domain-readouts">
<div><dt>左前窗</dt><dd id="frontLeftWindow">未知</dd></div>
<div><dt>右前窗</dt><dd id="frontRightWindow">未知</dd></div>
<div><dt>左后窗</dt><dd id="rearLeftWindow">未知</dd></div>
<div><dt>右后窗</dt><dd id="rearRightWindow">未知</dd></div>
</dl>
</article>
<article class="domain-panel lighting-panel" data-domain-panel="front_light" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="car-front"></i></span>
<div><strong>前大灯</strong><small>FB 0x38A · CH1</small></div>
<span class="device-state" id="frontLightState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>近/远光 · 雾灯 · 转向灯</p>
<div class="domain-summary-visual lighting-summary-visual" aria-hidden="true">
<span data-light-meter="lowBeamState"><i></i><b>近光</b></span>
<span data-light-meter="highBeamState"><i></i><b>远光</b></span>
<span data-light-meter="fogLightState"><i></i><b>雾灯</b></span>
<span data-light-meter="turnSignalState"><i></i><b>转向</b></span>
</div>
<dl class="domain-readouts">
<div><dt>近光灯</dt><dd id="lowBeamState">未知</dd></div>
<div><dt>远光灯</dt><dd id="highBeamState">未知</dd></div>
<div><dt>雾灯</dt><dd id="fogLightState">未知</dd></div>
<div><dt>转向灯</dt><dd id="turnSignalState">未知</dd></div>
</dl>
</article>
</div>
</section>
<section class="twin-band" aria-labelledby="twinTitle">
<div class="twin-heading">
<div class="twin-title-copy">
<h3 id="twinTitle">整车孪生界面</h3>
<div class="preset-indicator" id="presetIndicator" hidden>
<i data-lucide="bookmark-check"></i><span></span>
</div>
</div>
<div class="twin-toolbar">
<div class="twin-selector" role="tablist" aria-label="前排座椅孪生选择">
<button class="twin-seat-button active" type="button" role="tab" aria-selected="true" data-twin-seat="passenger">副驾座椅 · CH0</button>
<button class="twin-seat-button" type="button" role="tab" aria-selected="false" data-twin-seat="driver">主驾座椅 · CH1</button>
</div>
<button class="icon-button twin-reset" id="twinReset" type="button" title="复位 3D 视角" aria-label="复位 3D 视角">
<i data-lucide="rotate-3d"></i>
</button>
</div>
</div>
<div class="twin-stage" id="seatTwin" data-selected-seat="passenger">
<canvas id="seatTwinCanvas" data-vehicle-model="byd-tang-2024-reference" data-seat-layout="five-seat" data-active-seat="front-right" aria-label="通过模型动画展示 CH0 副驾座椅与 CH1 主驾座椅、空调气流、智能天幕、阅读灯、四门车窗与前大灯状态的整车数字孪生"></canvas>
<div class="twin-seat-label passenger"><i></i><span>副驾座椅</span><small>CH0 · 右前排</small></div>
<div class="twin-seat-label driver"><i></i><span>主驾座椅</span><small>CH1 · 左前排</small></div>
<div class="twin-orientation"><i data-lucide="move-down"></i><span>车头方向</span></div>
<aside class="twin-readout" aria-live="polite">
<div class="twin-readout-heading">
<div><strong id="twinSeatName">副驾座椅</strong><span id="twinSeatZone">CH0 · 右前排</span></div>
<span class="twin-feedback"><i></i>FEEDBACK</span>
</div>
<div class="twin-axis-list">
<div data-twin-axis="longitudinal"><span>水平位置</span><b>50%</b><i></i></div>
<div data-twin-axis="cushion_tilt"><span>坐垫俯仰</span><b>50%</b><i></i></div>
<div data-twin-axis="height"><span>座椅高度</span><b>50%</b><i></i></div>
<div data-twin-axis="backrest_recline"><span>靠背夹角</span><b>20% · 104°</b><i></i></div>
<div data-twin-axis="legrest_height"><span>腿托夹角</span><b>0% · 95°</b><i></i></div>
<div data-twin-axis="legrest_extension"><span>腿托伸缩</span><b>0%</b><i></i></div>
</div>
<div class="twin-thermal-row" aria-label="座椅热舒适状态">
<span data-twin-function="ventilation" data-level="0">
<i data-lucide="wind"></i>
<span class="twin-thermal-copy"><small>通风</small><b>关闭</b></span>
<span class="twin-level-meter" aria-hidden="true"><i></i><i></i><i></i></span>
</span>
<span data-twin-function="heating" data-level="0">
<i data-lucide="flame"></i>
<span class="twin-thermal-copy"><small>加热</small><b>关闭</b></span>
<span class="twin-level-meter" aria-hidden="true"><i></i><i></i><i></i></span>
</span>
</div>
</aside>
<div class="twin-fallback" id="twinFallback"><i data-lucide="loader-circle"></i><span>正在加载整车参考模型</span></div>
</div>
</section>
<section class="state-band">
<div class="seat-state-section">
<div class="section-title-row">
<div><h3>前排座椅状态</h3></div>
<span class="simulation-label" id="seatFeedbackModeLabel">CH0 / CH1 FEEDBACK</span>
</div>
<div class="seat-grid">
<article class="seat-card" data-seat="passenger">
<div class="seat-identity"><i data-lucide="armchair"></i><div><strong>副驾座椅</strong><span>右前排</span></div></div>
<span class="seat-channel-badge" data-seat-feedback="passenger">CH0 · 等待反馈</span>
<div class="dynamic-mode-state" data-dynamic-mode>
<i data-lucide="waves"></i><span>动态模式</span><b>未运行</b>
</div>
<div class="motion-state-grid" aria-live="polite"></div>
</article>
<article class="seat-card" data-seat="driver">
<div class="seat-identity"><i data-lucide="armchair"></i><div><strong>主驾座椅</strong><span>左前排</span></div></div>
<span class="seat-channel-badge" data-seat-feedback="driver">CH1 · 等待反馈</span>
<div class="dynamic-mode-state" data-dynamic-mode>
<i data-lucide="radio-tower"></i><span>车端 CAN FD</span><b>只读同步</b>
</div>
<div class="thermal-states">
<span data-function="ventilation"><i data-lucide="wind"></i>通风 <b>--</b></span>
<span data-function="heating"><i data-lucide="flame"></i>加热 <b>--</b></span>
<span data-function="massage"><i data-lucide="waves"></i>按摩 <b>--</b></span>
</div>
<div class="motion-state-grid" aria-live="polite"></div>
</article>
</div>
</div>
<div class="coverage-section">
<h3>当前控制边界</h3>
<dl>
<div><dt>空调 18–32℃ / 0–7 档</dt><dd class="available">可编译</dd></div>
<div><dt>天幕 / 遮阳帘 0–100%</dt><dd class="available">可编译</dd></div>
<div><dt>氛围灯 RGB / 亮度</dt><dd class="available">可编译</dd></div>
<div><dt>副驾座椅统一执行器</dt><dd class="simulated">t2g_primary_seat</dd></div>
<div><dt>主驾座椅车端执行器</dt><dd class="simulated">CH1 · 左前排</dd></div>
<div><dt>真实台架 CAN 下发</dt><dd class="blocked" id="hardwareCoverage">未配置</dd></div>
</dl>
</div>
</section>
<section class="detail-section result-workspace" aria-labelledby="resultWorkspaceTitle">
<header class="result-workspace-heading">
<div><h3 id="resultWorkspaceTitle">编排结果工作台</h3></div>
<span class="result-workspace-badge"><i data-lucide="route"></i>REQUEST → CONFIG → CAN → RESULT</span>
</header>
<nav class="tabs result-nav" aria-label="结果快速导航">
<button class="tab active" type="button" data-tab="trace"><span class="tab-index">01</span><i data-lucide="list-tree"></i><span class="tab-copy"><strong>决策轨迹</strong><small id="traceNavMeta">等待请求</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="config"><span class="tab-index">02</span><i data-lucide="layout-grid"></i><span class="tab-copy"><strong>配置卡片</strong><small id="configNavMeta">等待生成</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="plan"><span class="tab-index">03</span><i data-lucide="binary"></i><span class="tab-copy"><strong>CAN 计划</strong><small id="planNavMeta">等待编译</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="result"><span class="tab-index">04</span><i data-lucide="circle-check-big"></i><span class="tab-copy"><strong>执行结果</strong><small id="resultNavMeta">尚未执行</small></span><i data-lucide="arrow-down"></i></button>
</nav>
<div class="tab-panel active" id="panel-trace" role="tabpanel">
<div class="workspace-panel-title"><div><h3>决策轨迹与校验</h3></div><span class="workspace-panel-state" id="tracePanelState">IDLE</span></div>
<div class="trace-layout">
<div>
<div class="detail-heading"><h3>可审计决策摘要</h3><span id="providerUsed">--</span></div>
<ol class="decision-list" id="decisionList"><li>提交请求后显示识别目标、能力选择和约束来源。</li></ol>
</div>
<div>
<div class="detail-heading"><h3>阶段事件</h3><span id="traceDuration">0 ms</span></div>
<div class="trace-list" id="traceList"><div class="empty-line">暂无执行事件</div></div>
</div>
</div>
<div class="validation-block">
<div class="detail-heading"><h3>校验结果</h3><span id="validationCount">0 条</span></div>
<div id="validationList" class="validation-list"><div class="empty-line">等待配置生成</div></div>
</div>
</div>
<div class="tab-panel config-panel" id="panel-config" role="tabpanel">
<div class="config-panel-heading">
<div><h3>指令配置与硬件映射</h3></div>
<div class="config-actions"><button class="config-action" type="button" data-copy="config"><i data-lucide="copy"></i>复制 YAML</button><button class="config-action" type="button" data-download="config"><i data-lucide="download"></i>下载配置</button></div>
</div>
<div class="config-card-grid" id="configCards">
<div class="config-empty"><i data-lucide="panel-top-dashed"></i><strong>等待生成配置</strong><span>提交车舱控制指令后,这里会显示控制对象、目标参数和硬件信号。</span></div>
</div>
<details class="config-source-details">
<summary><span><i data-lucide="braces"></i>查看原始配置</span><small id="configFileName">functions/seat.yaml</small></summary>
<pre id="configCode"><code># 等待生成 FunctionConfig</code></pre>
</details>
</div>
<div class="tab-panel plan-panel" id="panel-plan" role="tabpanel">
<div class="workspace-panel-title plan-panel-heading"><div><h3>CAN 编译计划</h3></div><button class="config-action" type="button" data-copy="plan"><i data-lucide="copy"></i>复制计划</button></div>
<div class="frame-summary" id="frameSummary"><span>Frame ID</span><b>--</b><span>Payload</span><code>--</code><span>Mode</span><b>DRY-RUN</b></div>
<details class="config-source-details plan-source-details">
<summary><span><i data-lucide="binary"></i>查看原始 CAN 计划</span><small id="planFileName">command-plans/seat.yaml</small></summary>
<pre id="planCode"><code># 等待 CAN 编译</code></pre>
</details>
</div>
<div class="tab-panel result-panel" id="panel-result" role="tabpanel">
<div class="workspace-panel-title"><div><h3>执行结果与硬件回读</h3></div><span class="workspace-panel-state" id="resultPanelState">IDLE</span></div>
<div class="execution-summary" id="executionSummary">
<i data-lucide="circle-dashed"></i><div><strong>尚未执行</strong><span>运行结果将在此处显示</span></div>
</div>
<div class="result-list" id="resultList"></div>
</div>
</section>
</main>
</div>
<aside class="gesture-drawer" id="gestureDrawer" aria-hidden="true" aria-labelledby="gestureDrawerTitle">
<header class="gesture-drawer-header">
<div>
<h2 id="gestureDrawerTitle">手势控制</h2>
</div>
<button class="gesture-close" id="gestureCloseButton" type="button" aria-label="关闭手势控制"><i data-lucide="x"></i></button>
</header>
<div class="gesture-status-strip">
<span class="gesture-state-pill idle" id="gestureStatePill"><i></i><b>未启动</b></span>
<span id="gestureSafetyText">摄像头尚未开启</span>
</div>
<div class="gesture-camera-stage">
<video id="gestureVideo" playsinline muted></video>
<canvas id="gestureCanvas"></canvas>
<div class="gesture-camera-empty" id="gestureCameraEmpty">
<span><i data-lucide="scan-face"></i></span>
<strong>用手势控制副驾驶座椅</strong>
<p>画面只在本机处理,不上传视频。</p>
</div>
<div class="gesture-live-badge" id="gestureLiveBadge" hidden><i></i>LIVE</div>
</div>
<div class="gesture-now">
<div><span>当前手势</span><strong id="gestureDetected">—</strong></div>
<i data-lucide="arrow-right"></i>
<div><span>期望动作</span><strong id="gestureExpected">等待启动</strong></div>
</div>
<div class="gesture-progress"><i id="gestureProgressBar"></i></div>
<div class="gesture-actions">
<button class="gesture-primary" id="gestureStartButton" type="button"><i data-lucide="camera"></i><span>开启摄像头</span></button>
<button id="gestureStopButton" type="button" disabled><i data-lucide="octagon"></i>停止</button>
</div>
<section class="gesture-guide-card">
<div class="gesture-card-heading"><div><span>手势速查</span><small>张掌激活 · 握拳只停止</small></div><button id="gestureCustomizeButton" type="button"><i data-lucide="sliders-horizontal"></i>自定义</button></div>
<div class="gesture-map-grid" id="gestureMapGrid"></div>
<div class="gesture-customize" id="gestureCustomizePanel" hidden>
<div><span>识别到</span><strong id="gestureCustomizeToken">请先摆出手势</strong></div>
<select id="gestureActionSelect" aria-label="选择座椅动作"></select>
<button id="gestureBindButton" type="button">绑定当前手势</button>
<button id="gestureResetMapping" type="button">恢复默认</button>
</div>
</section>
<footer class="gesture-footnote"><i data-lucide="shield-check"></i><span>换动作无需重新张掌;连续 3 秒无有效动作后自动锁止。</span></footer>
</aside>
<div class="mode-gallery-backdrop" id="modeGalleryBackdrop" hidden>
<section class="mode-gallery" id="modeGallery" role="dialog" aria-modal="true" aria-labelledby="modeGalleryTitle">
<header class="mode-gallery-header">
<div>
<h2 id="modeGalleryTitle">管理功能模式</h2>
<p>按来源与运行方式整理,快速定位并管理模式</p>
</div>
<button class="mode-gallery-close" id="modeGalleryClose" type="button" aria-label="关闭功能模式管理"><i data-lucide="x"></i></button>
</header>
<div class="mode-gallery-summary"><span><i data-lucide="layers-3"></i>自定义模式 · 静态模式 · 动态模式</span><strong id="modeGallerySummaryCount">0</strong></div>
<div class="mode-gallery-groups" id="modeGalleryGroups" aria-live="polite"></div>
<footer class="mode-gallery-footer"><i data-lucide="info"></i><span>自定义模式支持编辑与删除;静态模式执行后保持目标状态;动态模式持续变化,可随时停止。</span></footer>
</section>
</div>
<div class="mode-editor-backdrop" id="modeEditorBackdrop" hidden>
<section class="mode-editor" role="dialog" aria-modal="true" aria-labelledby="modeEditorTitle">
<div class="mode-editor-setup">
<header class="mode-editor-header">
<div>
<h2 id="modeEditorTitle">编辑功能模式</h2>
</div>
<button id="modeEditorClose" type="button" aria-label="关闭模式编辑器"><i data-lucide="x"></i></button>
</header>
<div class="mode-editor-inputs">
<label class="mode-editor-field mode-editor-name-field" for="modeEditorName">
<span>模式名称</span>
<input id="modeEditorName" maxlength="64" autocomplete="off" placeholder="输入模式名称" />
</label>
<label class="mode-editor-field mode-editor-instruction-field" for="modeEditorInstruction">
<span>自然语言指令</span>
<textarea id="modeEditorInstruction" maxlength="1000" rows="3" placeholder="例如:空调24度、风量2档,打开遮阳帘"></textarea>
</label>
</div>
</div>
<section class="mode-editor-flow" aria-live="polite">
<header>
<span><i data-lucide="workflow"></i>控制指令流</span>
<div class="mode-editor-flow-tools">
<b id="modeEditorFlowCount">尚未生成</b>
<button id="modeEditorFlowExpand" type="button" aria-expanded="false" aria-label="展开控制指令区域" title="展开控制指令区域">
<i data-lucide="maximize-2"></i>
</button>
</div>
</header>
<div class="mode-editor-flow-preview empty" id="modeEditorFlowPreview">
<p>点击“生成指令”后,这里会显示由后端解析、校验并编译的控制指令流。</p>
</div>
</section>
<p class="mode-editor-status" id="modeEditorStatus">修改自然语言后需要重新生成指令,才能点击完成永久保存。</p>
<footer class="mode-editor-actions">
<button id="modeEditorCancel" type="button">取消</button>
<button class="mode-editor-execute" id="modeEditorExecute" type="button"><i data-lucide="wand-sparkles" data-icon="inline-start"></i><span>生成指令</span></button>
<button class="mode-editor-complete" id="modeEditorComplete" type="button" disabled><i data-lucide="save" data-icon="inline-start"></i><span>完成</span></button>
</footer>
</section>
</div>
<button class="glass-theme-toggle" id="glassThemeToggle" type="button" aria-label="切换到白日模式" aria-pressed="true" title="切换到白日模式">
<i data-lucide="sun" data-theme-icon="sun" aria-hidden="true"></i>
<i data-lucide="moon" data-theme-icon="moon" aria-hidden="true"></i>
<span class="visually-hidden">切换白日/夜间模式</span>
</button>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div id="app" aria-busy="true"></div>
<noscript>需要启用 JavaScript 才能使用智能座舱控制台。</noscript>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1394
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@n7/cognitive-agent-web-console",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"vue": "3.5.21"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.1",
"vite": "7.3.6"
}
}
+6 -6
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v305";
const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,22 +6,22 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=dual-seat-23",
"/network.js?v=android-pad-2",
"/app.js?v=dual-seat-140",
"/app.js?v=dual-seat-141",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-69",
"/console-v2.js?v=local-first-70",
"/console-v2.js?v=local-first-76",
"/self-extension.css?v=pet-settings-52",
"/self-extension.js?v=pet-settings-52",
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5",
"/assistant-chat.css?v=codex-assistant-29",
"/assistant-chat.js?v=codex-assistant-33",
"/assistant-chat.js?v=codex-assistant-34",
"/energy-monitor.css?v=energy-monitor-4",
"/energy-monitor.js?v=energy-monitor-4",
"/ui-consistency.css?v=spatial-ui-5",
"/codepen-glass-theme.css?v=creative-cloud-2",
"/ui-consistency.css?v=spatial-ui-8",
"/codepen-glass-theme.css?v=creative-cloud-5",
"/vue-ui.js?v=vue-presentation-3",
"/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3",
+584
View File
@@ -0,0 +1,584 @@
<script setup>
defineOptions({ name: "N7ConsoleApp" });
</script>
<template>
<div class="v2-boot-screen" id="v2BootScreen" role="status" aria-live="polite">
<span class="v2-boot-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<strong>511工程-智能座舱</strong>
<small>正在加载车机控制与实时状态</small>
</div>
<div class="workspace">
<aside class="command-panel">
<div class="panel-heading">
<h2>说一句,就调好了</h2>
</div>
<div class="mode-rail" aria-label="功能模式">
<div class="mode-rail-bar">
<div class="mode-rail-identity">
<span class="mode-rail-mark" aria-hidden="true"><i data-lucide="sparkles"></i></span>
<span><strong class="mode-rail-title">功能模式</strong><small>预设与我的模式</small></span>
</div>
<div class="mode-rail-content" id="modeRailContent" role="region" aria-label="功能模式列表">
<div class="mode-chips" tabindex="0" aria-label="快捷功能模式,可横向滚动" aria-live="polite">
<button type="button" data-mode-profile-id="zero_gravity" data-mode-origin="preset" data-mode-command="进入零重力模式"><i data-lucide="orbit"></i><span>零重力</span></button>
<button type="button" data-mode-profile-id="office" data-mode-origin="preset" data-mode-command="进入办公模式"><i data-lucide="briefcase"></i><span>办公</span></button>
<button type="button" data-mode-profile-id="nap" data-mode-origin="preset" data-mode-command="进入小憩模式"><i data-lucide="moon-star"></i><span>小憩</span></button>
</div>
</div>
<div class="mode-rail-actions">
<button class="mode-gallery-toggle" id="modeGalleryToggle" type="button" aria-expanded="false" aria-controls="modeGallery"><i data-lucide="settings-2" data-icon="inline-start"></i><span>管理</span><strong id="modeGalleryCount">0</strong></button>
<button class="mode-create-toggle" id="saveModeToggle" type="button"><i data-lucide="sparkles" data-icon="inline-start"></i><span>AI 新建</span></button>
<button class="mode-rail-toggle" id="modeRailToggle" type="button" aria-expanded="true" aria-controls="modeRailContent" aria-label="收起功能模式" title="收起功能模式">
<span class="visually-hidden" id="modeRailSelection">静态模式</span><i data-lucide="chevron-up"></i>
</button>
</div>
</div>
</div>
<form id="requestForm" autocomplete="off">
<section class="assistant-transcript" id="assistantTranscript" role="log" aria-live="polite" aria-label="AI助手对话">
<div class="assistant-messages" id="assistantMessages">
<article class="assistant-message assistant-message-assistant">
<span class="assistant-avatar" aria-hidden="true"><i data-lucide="waypoints"></i></span>
<div class="assistant-message-body">
<div class="assistant-message-content">你好,我是 AI助手。你可以向我提问,也可以直接交代需要完成的工作。</div>
</div>
</article>
</div>
</section>
<div class="field-label-row">
<label for="utterance">向 AI助手提问或交代工作</label>
<button class="context-reset" id="newConversation" type="button" title="开始新会话" aria-label="开始新会话">
<i data-lucide="message-square-plus"></i>
</button>
</div>
<div class="command-composer">
<textarea id="utterance" name="utterance" maxlength="4000" autocomplete="off" placeholder="向 AI助手提问或交代工作"></textarea>
<button class="speech-dictation" id="speechDictationButton" type="button" aria-label="开始语音输入" aria-pressed="false" title="开始语音输入">
<i class="speech-mic-icon" data-lucide="mic" aria-hidden="true"></i>
<span class="speech-waveform" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span>
<span class="speech-visually-hidden">开始语音输入</span>
</button>
<span class="speech-status" id="speechDictationStatus" role="status" aria-live="polite"></span>
<span class="composer-hint">Enter 发送 · Shift + Enter 换行</span>
</div>
<div class="field-group">
<span class="field-label">意图解析器</span>
<div class="segmented" role="group" aria-label="意图解析器">
<button type="button" class="segment active" data-provider="openai">
<i data-lucide="sparkles"></i>GPT
</button>
<button type="button" class="segment" data-provider="local">
<i data-lucide="braces"></i>本地规则
</button>
</div>
<p class="provider-note" id="providerNote">正在读取运行时配置</p>
</div>
<div class="field-group" id="reasoningField">
<span class="field-label">模型推理强度</span>
<div class="segmented reasoning-segmented" role="group" aria-label="模型推理强度">
<button type="button" class="segment reasoning-option" data-reasoning-effort="low">
<i data-lucide="zap"></i><span><strong>极速</strong><small>简单指令,优先响应速度</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
<button type="button" class="segment reasoning-option active" data-reasoning-effort="medium">
<i data-lucide="scale"></i><span><strong>均衡</strong><small>日常控制,兼顾速度与理解</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
<button type="button" class="segment reasoning-option" data-reasoning-effort="high">
<i data-lucide="brain"></i><span><strong>深度</strong><small>复杂联动、相对动作与约束</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
</div>
<p class="provider-note" id="reasoningNote">均衡模式兼顾自然语言解析精度与响应速度</p>
</div>
<div class="field-group">
<span class="field-label">执行目标</span>
<div class="segmented execution-mode-options" role="group" aria-label="执行目标">
<button type="button" class="mode-option" data-execution-mode="dry_run">
<i data-lucide="flask-conical"></i>模拟下发
</button>
<button type="button" class="mode-option active" id="hardwareMode" data-execution-mode="hardware">
<i data-lucide="radio-tower"></i>真实下发
</button>
</div>
</div>
<div class="field-group" id="vehicleTransportField">
<span class="field-label">车辆连接方式</span>
<div class="segmented vehicle-transport-options" role="group" aria-label="车辆连接方式">
<button type="button" class="mode-option active" data-control-transport="wired_can">
<i data-lucide="cable"></i>有线 CAN
</button>
<button type="button" class="mode-option" data-control-transport="wireless_mqtt">
<i data-lucide="wifi"></i>无线 CAN
</button>
</div>
</div>
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
<label class="field-label" for="simulationFault">故障注入场景</label>
<select id="simulationFault" aria-label="选择模拟硬件故障">
<option value="normal">正常反馈</option>
<option value="feedback_timeout">反馈超时</option>
<option value="adapter_disconnect">适配器断开</option>
<option value="motor_stall">电机堵转</option>
<option value="firmware_mismatch">固件不匹配</option>
<option value="partial_feedback">部分反馈</option>
</select>
<p class="provider-note">仅生成模拟 HIL 结果,不会打开物理 CAN 适配器或调用 IAR。</p>
</div>
<div class="field-group vehicle-field">
<label class="field-label" for="vehicleSelect">车型配置</label>
<div class="vehicle-select-control">
<i data-lucide="car-front"></i>
<select id="vehicleSelect" name="vehicle_id" aria-label="选择车型配置">
<option value="">加载车型配置</option>
</select>
</div>
<p class="provider-note" id="vehicleNote">正在读取座椅量程与角度标定</p>
</div>
<button class="primary-action" id="runButton" type="submit" aria-label="发送消息">
<i data-lucide="send-horizontal"></i>
<span>发送</span>
</button>
</form>
</aside>
<main class="main-panel">
<section class="request-header">
<div>
<div class="request-title-row">
<h2 id="resultTitle">等待请求</h2>
<span class="run-status idle" id="runStatus">IDLE</span>
</div>
<p id="requestMeta">Cabin / reference_vehicle / dry-run</p>
</div>
<div class="matrix-stat">
<i data-lucide="table-properties"></i>
<div><strong id="matrixCount">10 / 57</strong><span>报文 / 信号</span></div>
</div>
</section>
<section class="cabin-band" aria-labelledby="cabinStateTitle">
<div class="section-title-row cabin-heading">
<div class="cabin-title-copy">
<h3 id="cabinStateTitle">整车设备状态</h3>
</div>
<div class="cabin-channel-legend" aria-label="整车设备通道说明">
<span data-channel="ch0"><i>CH0</i>副驾座椅</span>
<span data-channel="ch1"><i>CH1</i>主驾 / 车身</span>
<span class="simulation-label" id="feedbackModeLabel">MATRIX-BACKED · SIMULATED FEEDBACK</span>
</div>
</div>
<div class="domain-grid">
<article class="domain-panel climate-panel" data-domain-panel="climate" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="fan"></i></span>
<div><strong>空调</strong><small>CMD 0x1DE · FB 0x404 · CH1</small></div>
<span class="device-state" id="climatePower">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>电源 · 温度 · 风量/风向</p>
<div class="climate-display">
<div class="zone-temperature">
<span>主驾</span><strong id="driverTemperature">--<sup>℃</sup></strong>
<small>风向<b id="driverAirDirection">-</b></small>
</div>
<div class="airflow-visual" id="airflowVisual" data-level="0" aria-label="空调风量">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</div>
<div class="zone-temperature passenger-temperature">
<span>副驾</span><strong id="passengerTemperature">--<sup>℃</sup></strong>
<small>风向<b id="passengerAirDirection">-</b></small>
</div>
</div>
<dl class="domain-readouts">
<div><dt>风量</dt><dd id="fanLevel">未知</dd></div>
<div><dt>循环</dt><dd id="cycleMode">未知</dd></div>
<div><dt>A/C</dt><dd id="acState">未知</dd></div>
<div><dt>前除霜</dt><dd id="defrostState">未知</dd></div>
</dl>
</article>
<article class="domain-panel vehicle-status-panel" data-domain-panel="vehicle_status" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="gauge"></i></span>
<div><strong>车辆状态</strong><small>FB 0x342 · 0x4EB · 0x407 · CH1</small></div>
<span class="device-state readonly-state" id="vehicleTelemetryState">只读</span>
</header>
<p class="domain-capability-line readonly-capability"><span>只读信号</span>档位 · 车速 · 四门锁状态</p>
<div class="vehicle-primary-readout">
<div><span>当前档位</span><strong id="vehicleGear">--</strong></div>
<div><span>实时车速</span><strong id="vehicleSpeed">-- km/h</strong></div>
</div>
<div class="door-state-grid" aria-label="四门锁状态">
<span data-door-state="front_left"><i></i>左前门<b id="frontLeftDoorState">未知</b></span>
<span data-door-state="front_right"><i></i>右前门<b id="frontRightDoorState">未知</b></span>
<span data-door-state="rear_left"><i></i>左后门<b id="rearLeftDoorState">未知</b></span>
<span data-door-state="rear_right"><i></i>右后门<b id="rearRightDoorState">未知</b></span>
</div>
</article>
<article class="domain-panel roof-panel" data-domain-panel="roof" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="panels-top-left"></i></span>
<div><strong>遮阳帘</strong><small>CMD 0x431 · FB 0x41A · CH1</small></div>
<span class="device-state" id="roofState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>开关 / 停止 · 位置</p>
<div class="domain-summary-visual shade-summary-visual" aria-hidden="true">
<span>闭合</span><i><b id="shadePositionMeter"></b></i><span>打开</span>
</div>
<dl class="domain-readouts">
<div><dt>遮阳帘</dt><dd id="shadePositionValue">未知</dd></div>
<div><dt>动作</dt><dd id="shadeMotionState">未知</dd></div>
<div><dt>初始化</dt><dd id="shadeInitializedState">未知</dd></div>
<div><dt>热保护</dt><dd id="shadeThermalState">未知</dd></div>
</dl>
</article>
<article class="domain-panel ambient-panel" data-domain-panel="ambient_light" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="lamp-ceiling"></i></span>
<div><strong>车内灯光</strong><small>INTERIOR / AMBIENT · CH1</small></div>
<span class="device-state" id="ambientPower">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>阅读灯 · 氛围亮度 · 色号</p>
<div class="domain-summary-visual ambient-summary-visual" aria-hidden="true">
<span data-ambient-zone="front"><i id="ambientFrontMeter"></i><b>前排</b></span>
<span data-ambient-zone="rear"><i id="ambientRearMeter"></i><b>后排</b></span>
</div>
<dl class="domain-readouts">
<div><dt>氛围灯</dt><dd id="ambientMode">未知</dd></div>
<div><dt>室内/阅读灯</dt><dd id="interiorLightPower">未知</dd></div>
<div><dt>氛围亮度</dt><dd id="ambientBrightness">- / -</dd></div>
<div><dt>氛围色号</dt><dd id="ambientColorIndex">- / -</dd></div>
</dl>
</article>
<article class="domain-panel window-panel" data-domain-panel="window" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="gallery-vertical-end"></i></span>
<div><strong>车窗</strong><small>CMD 0x431 · FB 0x388 · CH1</small></div>
<span class="device-state" id="windowState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>四窗独立 · 开/关/停 · 位置</p>
<div class="domain-summary-visual window-summary-visual" aria-hidden="true">
<span data-window-meter="front_left"><i></i><b>左前</b></span>
<span data-window-meter="front_right"><i></i><b>右前</b></span>
<span data-window-meter="rear_left"><i></i><b>左后</b></span>
<span data-window-meter="rear_right"><i></i><b>右后</b></span>
</div>
<dl class="domain-readouts">
<div><dt>左前窗</dt><dd id="frontLeftWindow">未知</dd></div>
<div><dt>右前窗</dt><dd id="frontRightWindow">未知</dd></div>
<div><dt>左后窗</dt><dd id="rearLeftWindow">未知</dd></div>
<div><dt>右后窗</dt><dd id="rearRightWindow">未知</dd></div>
</dl>
</article>
<article class="domain-panel lighting-panel" data-domain-panel="front_light" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="car-front"></i></span>
<div><strong>前大灯</strong><small>FB 0x38A · CH1</small></div>
<span class="device-state" id="frontLightState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>近/远光 · 雾灯 · 转向灯</p>
<div class="domain-summary-visual lighting-summary-visual" aria-hidden="true">
<span data-light-meter="lowBeamState"><i></i><b>近光</b></span>
<span data-light-meter="highBeamState"><i></i><b>远光</b></span>
<span data-light-meter="fogLightState"><i></i><b>雾灯</b></span>
<span data-light-meter="turnSignalState"><i></i><b>转向</b></span>
</div>
<dl class="domain-readouts">
<div><dt>近光灯</dt><dd id="lowBeamState">未知</dd></div>
<div><dt>远光灯</dt><dd id="highBeamState">未知</dd></div>
<div><dt>雾灯</dt><dd id="fogLightState">未知</dd></div>
<div><dt>转向灯</dt><dd id="turnSignalState">未知</dd></div>
</dl>
</article>
</div>
</section>
<section class="twin-band" aria-labelledby="twinTitle">
<div class="twin-heading">
<div class="twin-title-copy">
<h3 id="twinTitle">整车孪生界面</h3>
<div class="preset-indicator" id="presetIndicator" hidden>
<i data-lucide="bookmark-check"></i><span></span>
</div>
</div>
<div class="twin-toolbar">
<div class="twin-selector" role="tablist" aria-label="前排座椅孪生选择">
<button class="twin-seat-button active" type="button" role="tab" aria-selected="true" data-twin-seat="passenger">副驾座椅 · CH0</button>
<button class="twin-seat-button" type="button" role="tab" aria-selected="false" data-twin-seat="driver">主驾座椅 · CH1</button>
</div>
<button class="icon-button twin-reset" id="twinReset" type="button" title="复位 3D 视角" aria-label="复位 3D 视角">
<i data-lucide="rotate-3d"></i>
</button>
</div>
</div>
<div class="twin-stage" id="seatTwin" data-selected-seat="passenger">
<canvas id="seatTwinCanvas" data-vehicle-model="byd-tang-2024-reference" data-seat-layout="five-seat" data-active-seat="front-right" aria-label="通过模型动画展示 CH0 副驾座椅与 CH1 主驾座椅、空调气流、智能天幕、阅读灯、四门车窗与前大灯状态的整车数字孪生"></canvas>
<div class="twin-seat-label passenger"><i></i><span>副驾座椅</span><small>CH0 · 右前排</small></div>
<div class="twin-seat-label driver"><i></i><span>主驾座椅</span><small>CH1 · 左前排</small></div>
<div class="twin-orientation"><i data-lucide="move-down"></i><span>车头方向</span></div>
<aside class="twin-readout" aria-live="polite">
<div class="twin-readout-heading">
<div><strong id="twinSeatName">副驾座椅</strong><span id="twinSeatZone">CH0 · 右前排</span></div>
<span class="twin-feedback"><i></i>FEEDBACK</span>
</div>
<div class="twin-axis-list">
<div data-twin-axis="longitudinal"><span>水平位置</span><b>50%</b><i></i></div>
<div data-twin-axis="cushion_tilt"><span>坐垫俯仰</span><b>50%</b><i></i></div>
<div data-twin-axis="height"><span>座椅高度</span><b>50%</b><i></i></div>
<div data-twin-axis="backrest_recline"><span>靠背夹角</span><b>20% · 104°</b><i></i></div>
<div data-twin-axis="legrest_height"><span>腿托夹角</span><b>0% · 95°</b><i></i></div>
<div data-twin-axis="legrest_extension"><span>腿托伸缩</span><b>0%</b><i></i></div>
</div>
<div class="twin-thermal-row" aria-label="座椅热舒适状态">
<span data-twin-function="ventilation" data-level="0">
<i data-lucide="wind"></i>
<span class="twin-thermal-copy"><small>通风</small><b>关闭</b></span>
<span class="twin-level-meter" aria-hidden="true"><i></i><i></i><i></i></span>
</span>
<span data-twin-function="heating" data-level="0">
<i data-lucide="flame"></i>
<span class="twin-thermal-copy"><small>加热</small><b>关闭</b></span>
<span class="twin-level-meter" aria-hidden="true"><i></i><i></i><i></i></span>
</span>
</div>
</aside>
<div class="twin-fallback" id="twinFallback"><i data-lucide="loader-circle"></i><span>正在加载整车参考模型</span></div>
</div>
</section>
<section class="state-band">
<div class="seat-state-section">
<div class="section-title-row">
<div><h3>前排座椅状态</h3></div>
<span class="simulation-label" id="seatFeedbackModeLabel">CH0 / CH1 FEEDBACK</span>
</div>
<div class="seat-grid">
<article class="seat-card" data-seat="passenger">
<div class="seat-identity"><i data-lucide="armchair"></i><div><strong>副驾座椅</strong><span>右前排</span></div></div>
<span class="seat-channel-badge" data-seat-feedback="passenger">CH0 · 等待反馈</span>
<div class="dynamic-mode-state" data-dynamic-mode>
<i data-lucide="waves"></i><span>动态模式</span><b>未运行</b>
</div>
<div class="motion-state-grid" aria-live="polite"></div>
</article>
<article class="seat-card" data-seat="driver">
<div class="seat-identity"><i data-lucide="armchair"></i><div><strong>主驾座椅</strong><span>左前排</span></div></div>
<span class="seat-channel-badge" data-seat-feedback="driver">CH1 · 等待反馈</span>
<div class="dynamic-mode-state" data-dynamic-mode>
<i data-lucide="radio-tower"></i><span>车端 CAN FD</span><b>只读同步</b>
</div>
<div class="thermal-states">
<span data-function="ventilation"><i data-lucide="wind"></i>通风 <b>--</b></span>
<span data-function="heating"><i data-lucide="flame"></i>加热 <b>--</b></span>
<span data-function="massage"><i data-lucide="waves"></i>按摩 <b>--</b></span>
</div>
<div class="motion-state-grid" aria-live="polite"></div>
</article>
</div>
</div>
<div class="coverage-section">
<h3>当前控制边界</h3>
<dl>
<div><dt>空调 18–32℃ / 0–7 档</dt><dd class="available">可编译</dd></div>
<div><dt>天幕 / 遮阳帘 0–100%</dt><dd class="available">可编译</dd></div>
<div><dt>氛围灯 RGB / 亮度</dt><dd class="available">可编译</dd></div>
<div><dt>副驾座椅统一执行器</dt><dd class="simulated">t2g_primary_seat</dd></div>
<div><dt>主驾座椅车端执行器</dt><dd class="simulated">CH1 · 左前排</dd></div>
<div><dt>真实台架 CAN 下发</dt><dd class="blocked" id="hardwareCoverage">未配置</dd></div>
</dl>
</div>
</section>
<section class="detail-section result-workspace" aria-labelledby="resultWorkspaceTitle">
<header class="result-workspace-heading">
<div><h3 id="resultWorkspaceTitle">编排结果工作台</h3></div>
<span class="result-workspace-badge"><i data-lucide="route"></i>REQUEST → CONFIG → CAN → RESULT</span>
</header>
<nav class="tabs result-nav" aria-label="结果快速导航">
<button class="tab active" type="button" data-tab="trace"><span class="tab-index">01</span><i data-lucide="list-tree"></i><span class="tab-copy"><strong>决策轨迹</strong><small id="traceNavMeta">等待请求</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="config"><span class="tab-index">02</span><i data-lucide="layout-grid"></i><span class="tab-copy"><strong>配置卡片</strong><small id="configNavMeta">等待生成</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="plan"><span class="tab-index">03</span><i data-lucide="binary"></i><span class="tab-copy"><strong>CAN 计划</strong><small id="planNavMeta">等待编译</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="result"><span class="tab-index">04</span><i data-lucide="circle-check-big"></i><span class="tab-copy"><strong>执行结果</strong><small id="resultNavMeta">尚未执行</small></span><i data-lucide="arrow-down"></i></button>
</nav>
<div class="tab-panel active" id="panel-trace" role="tabpanel">
<div class="workspace-panel-title"><div><h3>决策轨迹与校验</h3></div><span class="workspace-panel-state" id="tracePanelState">IDLE</span></div>
<div class="trace-layout">
<div>
<div class="detail-heading"><h3>可审计决策摘要</h3><span id="providerUsed">--</span></div>
<ol class="decision-list" id="decisionList"><li>提交请求后显示识别目标、能力选择和约束来源。</li></ol>
</div>
<div>
<div class="detail-heading"><h3>阶段事件</h3><span id="traceDuration">0 ms</span></div>
<div class="trace-list" id="traceList"><div class="empty-line">暂无执行事件</div></div>
</div>
</div>
<div class="validation-block">
<div class="detail-heading"><h3>校验结果</h3><span id="validationCount">0 条</span></div>
<div id="validationList" class="validation-list"><div class="empty-line">等待配置生成</div></div>
</div>
</div>
<div class="tab-panel config-panel" id="panel-config" role="tabpanel">
<div class="config-panel-heading">
<div><h3>指令配置与硬件映射</h3></div>
<div class="config-actions"><button class="config-action" type="button" data-copy="config"><i data-lucide="copy"></i>复制 YAML</button><button class="config-action" type="button" data-download="config"><i data-lucide="download"></i>下载配置</button></div>
</div>
<div class="config-card-grid" id="configCards">
<div class="config-empty"><i data-lucide="panel-top-dashed"></i><strong>等待生成配置</strong><span>提交车舱控制指令后,这里会显示控制对象、目标参数和硬件信号。</span></div>
</div>
<details class="config-source-details">
<summary><span><i data-lucide="braces"></i>查看原始配置</span><small id="configFileName">functions/seat.yaml</small></summary>
<pre id="configCode"><code># 等待生成 FunctionConfig</code></pre>
</details>
</div>
<div class="tab-panel plan-panel" id="panel-plan" role="tabpanel">
<div class="workspace-panel-title plan-panel-heading"><div><h3>CAN 编译计划</h3></div><button class="config-action" type="button" data-copy="plan"><i data-lucide="copy"></i>复制计划</button></div>
<div class="frame-summary" id="frameSummary"><span>Frame ID</span><b>--</b><span>Payload</span><code>--</code><span>Mode</span><b>DRY-RUN</b></div>
<details class="config-source-details plan-source-details">
<summary><span><i data-lucide="binary"></i>查看原始 CAN 计划</span><small id="planFileName">command-plans/seat.yaml</small></summary>
<pre id="planCode"><code># 等待 CAN 编译</code></pre>
</details>
</div>
<div class="tab-panel result-panel" id="panel-result" role="tabpanel">
<div class="workspace-panel-title"><div><h3>执行结果与硬件回读</h3></div><span class="workspace-panel-state" id="resultPanelState">IDLE</span></div>
<div class="execution-summary" id="executionSummary">
<i data-lucide="circle-dashed"></i><div><strong>尚未执行</strong><span>运行结果将在此处显示</span></div>
</div>
<div class="result-list" id="resultList"></div>
</div>
</section>
</main>
</div>
<aside class="gesture-drawer" id="gestureDrawer" aria-hidden="true" aria-labelledby="gestureDrawerTitle">
<header class="gesture-drawer-header">
<div>
<h2 id="gestureDrawerTitle">手势控制</h2>
</div>
<button class="gesture-close" id="gestureCloseButton" type="button" aria-label="关闭手势控制"><i data-lucide="x"></i></button>
</header>
<div class="gesture-status-strip">
<span class="gesture-state-pill idle" id="gestureStatePill"><i></i><b>未启动</b></span>
<span id="gestureSafetyText">摄像头尚未开启</span>
</div>
<div class="gesture-camera-stage">
<video id="gestureVideo" playsinline muted></video>
<canvas id="gestureCanvas"></canvas>
<div class="gesture-camera-empty" id="gestureCameraEmpty">
<span><i data-lucide="scan-face"></i></span>
<strong>用手势控制副驾驶座椅</strong>
<p>画面只在本机处理,不上传视频。</p>
</div>
<div class="gesture-live-badge" id="gestureLiveBadge" hidden><i></i>LIVE</div>
</div>
<div class="gesture-now">
<div><span>当前手势</span><strong id="gestureDetected">—</strong></div>
<i data-lucide="arrow-right"></i>
<div><span>期望动作</span><strong id="gestureExpected">等待启动</strong></div>
</div>
<div class="gesture-progress"><i id="gestureProgressBar"></i></div>
<div class="gesture-actions">
<button class="gesture-primary" id="gestureStartButton" type="button"><i data-lucide="camera"></i><span>开启摄像头</span></button>
<button id="gestureStopButton" type="button" disabled><i data-lucide="octagon"></i>停止</button>
</div>
<section class="gesture-guide-card">
<div class="gesture-card-heading"><div><span>手势速查</span><small>张掌激活 · 握拳只停止</small></div><button id="gestureCustomizeButton" type="button"><i data-lucide="sliders-horizontal"></i>自定义</button></div>
<div class="gesture-map-grid" id="gestureMapGrid"></div>
<div class="gesture-customize" id="gestureCustomizePanel" hidden>
<div><span>识别到</span><strong id="gestureCustomizeToken">请先摆出手势</strong></div>
<select id="gestureActionSelect" aria-label="选择座椅动作"></select>
<button id="gestureBindButton" type="button">绑定当前手势</button>
<button id="gestureResetMapping" type="button">恢复默认</button>
</div>
</section>
<footer class="gesture-footnote"><i data-lucide="shield-check"></i><span>换动作无需重新张掌;连续 3 秒无有效动作后自动锁止。</span></footer>
</aside>
<div class="mode-gallery-backdrop" id="modeGalleryBackdrop" hidden>
<section class="mode-gallery" id="modeGallery" role="dialog" aria-modal="true" aria-labelledby="modeGalleryTitle">
<header class="mode-gallery-header">
<div>
<h2 id="modeGalleryTitle">管理功能模式</h2>
<p>按来源与运行方式整理,快速定位并管理模式</p>
</div>
<button class="mode-gallery-close" id="modeGalleryClose" type="button" aria-label="关闭功能模式管理"><i data-lucide="x"></i></button>
</header>
<div class="mode-gallery-summary"><span><i data-lucide="layers-3"></i>自定义模式 · 静态模式 · 动态模式</span><strong id="modeGallerySummaryCount">0</strong></div>
<div class="mode-gallery-groups" id="modeGalleryGroups" aria-live="polite"></div>
<footer class="mode-gallery-footer"><i data-lucide="info"></i><span>自定义模式支持编辑与删除;静态模式执行后保持目标状态;动态模式持续变化,可随时停止。</span></footer>
</section>
</div>
<div class="mode-editor-backdrop" id="modeEditorBackdrop" hidden>
<section class="mode-editor" role="dialog" aria-modal="true" aria-labelledby="modeEditorTitle">
<div class="mode-editor-setup">
<header class="mode-editor-header">
<div>
<h2 id="modeEditorTitle">编辑功能模式</h2>
</div>
<button id="modeEditorClose" type="button" aria-label="关闭模式编辑器"><i data-lucide="x"></i></button>
</header>
<div class="mode-editor-inputs">
<label class="mode-editor-field mode-editor-name-field" for="modeEditorName">
<span>模式名称</span>
<input id="modeEditorName" maxlength="64" autocomplete="off" placeholder="输入模式名称" />
</label>
<label class="mode-editor-field mode-editor-instruction-field" for="modeEditorInstruction">
<span>自然语言指令</span>
<textarea id="modeEditorInstruction" maxlength="1000" rows="3" placeholder="例如:空调24度、风量2档,打开遮阳帘"></textarea>
</label>
</div>
</div>
<section class="mode-editor-flow" aria-live="polite">
<header>
<span><i data-lucide="workflow"></i>控制指令流</span>
<div class="mode-editor-flow-tools">
<b id="modeEditorFlowCount">尚未生成</b>
<button id="modeEditorFlowExpand" type="button" aria-expanded="false" aria-label="展开控制指令区域" title="展开控制指令区域">
<i data-lucide="maximize-2"></i>
</button>
</div>
</header>
<div class="mode-editor-flow-preview empty" id="modeEditorFlowPreview">
<p>点击“生成指令”后,这里会显示由后端解析、校验并编译的控制指令流。</p>
</div>
</section>
<p class="mode-editor-status" id="modeEditorStatus">修改自然语言后需要重新生成指令,才能点击完成永久保存。</p>
<footer class="mode-editor-actions">
<button id="modeEditorCancel" type="button">取消</button>
<button class="mode-editor-execute" id="modeEditorExecute" type="button"><i data-lucide="wand-sparkles" data-icon="inline-start"></i><span>生成指令</span></button>
<button class="mode-editor-complete" id="modeEditorComplete" type="button" disabled><i data-lucide="save" data-icon="inline-start"></i><span>完成</span></button>
</footer>
</section>
</div>
<button class="glass-theme-toggle" id="glassThemeToggle" type="button" aria-label="切换到白日模式" aria-pressed="true" title="切换到白日模式">
<i data-lucide="sun" data-theme-icon="sun" aria-hidden="true"></i>
<i data-lucide="moon" data-theme-icon="moon" aria-hidden="true"></i>
<span class="visually-hidden">切换白日/夜间模式</span>
</button>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</template>
+33
View File
@@ -0,0 +1,33 @@
import { createApp, nextTick } from "vue";
import App from "./App.vue";
import "../styles.css";
import "../console-v2.css";
import "../self-extension.css";
import "../speech-input.css";
import "../assistant-chat.css";
import "../energy-monitor.css";
import "../ui-consistency.css";
import "../codepen-glass-theme.css";
import "./styles/design-system.css";
import "../vue-ui.js";
const application = createApp(App);
application.config.errorHandler = (error, instance, info) => {
console.error("N7 Vue application error", { error, instance, info });
};
application.mount("#app");
document.querySelector("#app")?.removeAttribute("aria-busy");
document.documentElement.classList.add("vue-vite-application");
document.documentElement.dataset.vueVersion = application.version || "3";
nextTick(() => {
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
window.dispatchEvent(new CustomEvent("cognitive-agent:vue-mounted"));
});
window.CognitiveAgentVueApp = Object.freeze({
application,
version: application.version || "3"
});
@@ -0,0 +1,275 @@
/* N7 Web Console design-system contract.
* Loaded last by Vite so every legacy surface consumes one geometry,
* typography, state and elevation scale while the runtime is migrated to
* Vue components incrementally. */
:root {
--n7-control-height-sm: 32px;
--n7-control-height: 38px;
--n7-icon-button-size: 38px;
--n7-radius-control: 10px;
--n7-radius-card: 14px;
--n7-radius-section: 16px;
--n7-radius-panel: 18px;
--n7-radius-dialog: 22px;
--n7-font-caption: 12px;
--n7-font-body: 13px;
--n7-font-card-title: 14px;
--n7-font-panel-title: 16px;
--n7-font-dialog-title: 22px;
--n7-shadow-card: inset 0 1px 0 var(--glass-highlight), 0 7px 20px color-mix(in oklch, var(--foreground) 8%, transparent);
--n7-shadow-panel: inset 0 1px 0 var(--glass-highlight), 0 14px 34px color-mix(in oklch, var(--foreground) 13%, transparent);
--n7-shadow-dialog: inset 0 1px 0 var(--glass-highlight), 0 28px 84px color-mix(in oklch, var(--foreground) 28%, transparent);
}
#app {
min-width: inherit;
min-height: 100dvh;
display: contents;
}
/* Workspace panels: one top-level geometry, distinct from their children. */
body.console-v2.v2-passive-display.v2-interactive-sync :is(
.main-panel > .v2-mode-rail,
#assistantDrawer,
.joint-twin-shell > .twin-band,
#v2DevicePanel,
#v2ExtensionPanel .v2-extension-sync-mount
) {
border-radius: var(--n7-radius-panel) !important;
box-shadow: var(--n7-shadow-panel) !important;
}
body.console-v2 :is(
.v2-sync-transport-field,
.v2-sync-execution-field,
.v2-can-command-panel,
.v2-nfc-inline,
.v2-extension-gateway,
.v2-extension-synced-device,
.v2-extension-empty,
.v2-pet-card,
.v2-pet-presence-card,
.v2-device-card,
.v2-synced-device,
.mode-gallery-card,
.mode-editor-command-card,
.energy-kpi,
.energy-device-row,
.assistant-dispatched-controls,
.assistant-mode-draft,
.assistant-mode-created,
.assistant-control-suggestion
) {
border-radius: var(--n7-radius-card) !important;
box-shadow: var(--n7-shadow-card) !important;
}
body.console-v2 :is(
.v2-sync-log,
.v2-pet-card-region,
.mode-gallery-group,
.mode-editor-flow,
.v2-nfc-stage,
.energy-trend-card,
.energy-devices-card
) {
border-radius: var(--n7-radius-section) !important;
}
/* Only actionable cards float. Read-only panels remain spatially stable. */
body.console-v2 :is(
.v2-sync-log,
.v2-pet-card-region,
.mode-editor-flow,
.energy-trend-card,
.energy-devices-card
):is(:hover, :focus-within) {
transform: none !important;
box-shadow: var(--n7-shadow-card) !important;
}
/* The current DOM uses .twin-selector; the previous glass selector targeted
* the removed .twin-seat-switch class and produced white-on-white tabs. */
body.console-v2 .joint-twin-shell :is(.twin-selector, .twin-selector button, .twin-reset, .twin-focus-badge) {
color: var(--muted-foreground) !important;
background: var(--glass-card) !important;
border-color: var(--glass-line) !important;
box-shadow: inset 0 1px 0 var(--glass-highlight) !important;
}
body.console-v2 .joint-twin-shell .twin-selector button.active {
color: var(--primary-foreground) !important;
background: var(--primary) !important;
border-color: color-mix(in oklch, var(--primary) 72%, white 18%) !important;
}
/* Shared heading hierarchy. */
body.console-v2 :is(
#assistantDrawer h2,
.joint-twin-shell .twin-heading h3,
.v2-device-sync-board > header h3,
.v2-extension-sync-heading h3,
.v2-mode-rail .mode-rail-title
) {
font-size: var(--n7-font-panel-title) !important;
font-weight: 750 !important;
line-height: 1.25 !important;
letter-spacing: -0.02em;
}
/* The log title shares a 217 px header with its filter, so it needs the
* compact card-title scale instead of the full panel-title scale. */
body.console-v2 .v2-sync-log h3 {
min-width: 0;
margin: 0;
overflow: hidden;
font-size: var(--n7-font-card-title) !important;
font-weight: 750 !important;
line-height: 1.25 !important;
letter-spacing: -0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
body.console-v2 :is(
.mode-gallery-header h2,
.mode-editor-header h2,
.v2-extension-dialog > header h2,
.v2-extension-dialog form > header h2,
.energy-monitor-heading h2
) {
font-size: var(--n7-font-dialog-title) !important;
font-weight: 750 !important;
line-height: 1.2 !important;
}
body.console-v2 :is(
.mode-gallery-card-heading strong,
.v2-device-card-focus strong,
.v2-nfc-inline strong,
.v2-extension-gateway strong,
.v2-pet-card > header > strong,
.energy-trend-card h3,
.energy-devices-card h3
) {
font-size: var(--n7-font-card-title) !important;
line-height: 1.35 !important;
}
body.console-v2 :is(
.mode-editor-status,
.mode-gallery-footer,
.v2-pet-settings-footnote,
.v2-pet-card-fields > label > span,
.v2-pet-nfc-field output,
.energy-kpi > small,
.energy-kpi > em,
.energy-devices-card > footer
) {
font-size: var(--n7-font-caption) !important;
line-height: 1.45 !important;
}
/* One dialog radius, elevation and close-button size. */
body.console-v2 :is(
.mode-gallery,
.mode-editor,
.v2-nfc-dialog,
.v2-gscu-tcp-dialog,
.v2-pet-settings-dialog,
.energy-monitor-page
) {
border-radius: var(--n7-radius-dialog) !important;
box-shadow: var(--n7-shadow-dialog) !important;
}
body.console-v2 :is(
.mode-gallery-close,
.mode-editor-header > button,
.v2-extension-dialog [data-dialog-close],
.v2-extension-dialog [data-close-extension-dialog],
.v2-extension-dialog [data-close-nfc-dialog],
#energyMonitorClose
) {
width: var(--n7-icon-button-size) !important;
height: var(--n7-icon-button-size) !important;
min-width: var(--n7-icon-button-size);
min-height: var(--n7-icon-button-size);
border-radius: var(--n7-radius-control) !important;
}
/* Compact icon actions still meet a usable hit target without becoming a
* second visual system. */
body.console-v2 :is(
.twin-reset,
.mode-gallery-edit,
.mode-gallery-delete,
.v2-pet-card > header > button
) {
width: var(--n7-control-height-sm) !important;
height: var(--n7-control-height-sm) !important;
min-width: var(--n7-control-height-sm);
min-height: var(--n7-control-height-sm);
border-radius: var(--n7-radius-control) !important;
}
/* Offline hardware is a destructive state, not a primary/active state. */
body.console-v2 .v2-nfc-stage:has(.v2-nfc-waiting.offline) > header {
color: var(--status-error-foreground) !important;
background: color-mix(in oklch, var(--destructive) 16%, var(--card)) !important;
border-bottom: 1px solid color-mix(in oklch, var(--destructive) 32%, var(--border));
}
body.console-v2 .v2-nfc-stage:has(.v2-nfc-waiting.offline) > header :is(span, small, h3, p, b) {
color: var(--status-error-foreground) !important;
}
/* Keep the global appearance control out of content and below every modal. */
.glass-theme-toggle {
left: max(18px, env(safe-area-inset-left)) !important;
bottom: max(18px, env(safe-area-inset-bottom)) !important;
z-index: 1100 !important;
}
body.mode-gallery-open .glass-theme-toggle,
body.mode-editor-open .glass-theme-toggle,
body:has(.v2-extension-dialog[open]) .glass-theme-toggle,
body:has(.energy-monitor-layer:not([hidden])) .glass-theme-toggle {
opacity: 0;
pointer-events: none;
}
/* The sidebar reserves the floating control's footprint instead of allowing
* it to cover the log timeline. */
body.console-v2 .v2-sync-log {
margin-bottom: 58px;
}
/* Top mode rail communicates that more content is available. */
body.console-v2 .v2-mode-rail .mode-rail-content {
position: relative;
min-width: 0;
}
body.console-v2 .v2-mode-rail .mode-rail-content::after {
width: 28px;
content: "";
position: absolute;
inset: 0 0 0 auto;
pointer-events: none;
background: linear-gradient(90deg, transparent, color-mix(in oklch, var(--glass-panel) 94%, transparent));
}
@media (max-width: 1360px) {
.glass-theme-toggle {
width: 42px !important;
height: 42px !important;
}
}
@media (prefers-reduced-motion: reduce) {
body.console-v2 * {
scroll-behavior: auto !important;
}
}
+79 -12
View File
@@ -289,29 +289,96 @@
/* Delivery states use the same semantic feedback language in the sidebar and
in the compact assistant command card. */
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-delivery:is(
[data-delivery-state="blocked"],
[data-delivery-state="failed"]
) {
.v2-can-command-list
li[data-can-channel]
.v2-can-command-delivery[data-delivery-state="failed"] {
color: var(--destructive);
background: color-mix(in oklch, var(--destructive) 10%, var(--card));
border-color: color-mix(in oklch, var(--destructive) 28%, var(--border));
}
.assistant-dispatched-controls-list > li:is(
[data-delivery-state="blocked"],
[data-delivery-state="failed"]
) {
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list
li[data-can-channel]
.v2-can-command-delivery[data-delivery-state="blocked"] {
color: var(--status-warning-foreground);
background: var(--status-warning-soft);
border-color: color-mix(in oklch, var(--status-warning) 38%, var(--border));
}
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list
li[data-can-channel]
.v2-can-command-delivery[data-delivery-state="aborted"] {
color: var(--muted-foreground);
background: var(--muted);
border-color: var(--border);
}
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list
li[data-can-channel]
.v2-can-command-delivery-detail {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px;
margin: 1px 0 2px;
padding: 5px 7px;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--muted) 62%, transparent);
border-left: 2px solid var(--border);
border-radius: var(--ui-radius-control-inner);
font-size: 9px;
line-height: 1.45;
white-space: normal;
}
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list
li[data-delivery-state="failed"]
.v2-can-command-delivery-detail {
color: var(--status-error-foreground);
border-left-color: var(--destructive);
}
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list
li[data-delivery-state="blocked"]
.v2-can-command-delivery-detail {
color: var(--status-warning-foreground);
border-left-color: var(--status-warning);
}
.assistant-dispatched-controls-list > li[data-delivery-state="failed"] {
background: color-mix(in oklch, var(--destructive) 4%, var(--card));
}
.assistant-dispatched-controls-list > li:is(
[data-delivery-state="blocked"],
[data-delivery-state="failed"]
) .assistant-dispatched-control-icon {
.assistant-dispatched-controls-list
> li[data-delivery-state="failed"]
.assistant-dispatched-control-icon {
color: var(--destructive);
}
.assistant-dispatched-controls-list > li[data-delivery-state="blocked"] {
background: var(--status-warning-soft);
}
.assistant-dispatched-controls-list
> li[data-delivery-state="blocked"]
.assistant-dispatched-control-icon {
color: var(--status-warning-foreground);
}
.assistant-dispatched-controls-list > li[data-delivery-state="aborted"] {
background: color-mix(in oklch, var(--muted) 68%, transparent);
}
.assistant-dispatched-controls-list
> li[data-delivery-state="aborted"]
.assistant-dispatched-control-icon {
color: var(--muted-foreground);
}
.console-v2 :is(
#v2PanelControlDock > .v2-panel-control-button,
.v2-mode-rail .mode-rail-actions > button,
+94
View File
@@ -0,0 +1,94 @@
import { cpSync, copyFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
const webRoot = dirname(fileURLToPath(import.meta.url));
const outputRoot = resolve(webRoot, "dist");
const backendOrigin = "http://127.0.0.1:8000";
const runtimeDirectories = ["assets", "icons", "vendor"];
const runtimeFiles = [
"manifest.webmanifest",
"service-worker.js",
"speech-audio-worklet.js",
"login.html",
"login.css",
"login.js",
"hardware-control.html",
"hardware-control.css",
"hardware-control.js",
"network.js",
"app.js",
"gesture-control.js",
"pwa.js",
"console-v2.js",
"self-extension.js",
"speech-input.js",
"assistant-chat.js",
"energy-monitor.js",
"styles.css",
"console-v2.css",
"self-extension.css",
"speech-input.css",
"assistant-chat.css",
"energy-monitor.css",
"ui-consistency.css",
"codepen-glass-theme.css",
"vue-ui.js"
];
function copyRuntimeAssets() {
return {
name: "copy-n7-runtime-assets",
apply: "build",
closeBundle() {
for (const directory of runtimeDirectories) {
const source = resolve(webRoot, directory);
if (existsSync(source)) {
cpSync(source, resolve(outputRoot, directory), { recursive: true });
}
}
for (const file of runtimeFiles) {
const source = resolve(webRoot, file);
if (!existsSync(source)) continue;
const target = resolve(outputRoot, file);
mkdirSync(dirname(target), { recursive: true });
copyFileSync(source, target);
}
}
};
}
function backendProxy() {
return {
target: backendOrigin,
changeOrigin: true,
configure(proxy) {
proxy.on("proxyReq", (request) => request.setHeader("origin", backendOrigin));
}
};
}
export default defineConfig({
root: webRoot,
publicDir: false,
plugins: [vue(), copyRuntimeAssets()],
server: {
strictPort: true,
fs: { allow: [webRoot] },
proxy: {
"/api": backendProxy(),
"/artifacts": backendProxy()
}
},
build: {
outDir: outputRoot,
emptyOutDir: true,
assetsDir: "vite-assets",
target: "es2022",
sourcemap: true
}
});
+2 -13
View File
@@ -1,20 +1,9 @@
import { Fragment, createApp, h, nextTick, reactive } from "vue";
(function () {
"use strict";
const VUE_VERSION = "3.5.21";
const vue = window.Vue;
const unavailable = {
available: false,
version: VUE_VERSION,
renderDispatchedControls: () => false,
renderModeChips: () => false,
};
if (!vue?.createApp || !vue?.h || !vue?.reactive) {
window.CognitiveAgentVueUI = unavailable;
return;
}
const { Fragment, createApp, h, nextTick, reactive } = vue;
const modeMounts = new WeakMap();
const commandMounts = new WeakMap();
const text = (value, fallback = "") => String(value ?? fallback);
+3
View File
@@ -18,6 +18,8 @@ src/cognitive_agent/application/codex_assistant.py
src/cognitive_agent/application/conversation_state.py
src/cognitive_agent/application/direct_can_control.py
src/cognitive_agent/application/domain_runtime.py
src/cognitive_agent/application/extension_control.py
src/cognitive_agent/application/extension_toy_publisher.py
src/cognitive_agent/application/gesture_bridge.py
src/cognitive_agent/application/mode_profile.py
src/cognitive_agent/application/mode_profile_intent.py
@@ -28,6 +30,7 @@ src/cognitive_agent/application/native_can_feedback.py
src/cognitive_agent/application/native_can_intent.py
src/cognitive_agent/application/native_can_relative.py
src/cognitive_agent/application/native_can_runtime.py
src/cognitive_agent/application/pet_cards.py
src/cognitive_agent/application/scene_composer.py
src/cognitive_agent/application/scene_templates.py
src/cognitive_agent/application/seat_calibration.py
@@ -1850,7 +1850,7 @@ class AssistantCapabilityCatalog:
]
if not peripherals:
return CapabilityAnswer(
"工程支持通过 NFC 绑定并学习红外外设;当前没有同时满足“已启用且对应 NFC 卡在场”的可执行外设能力。",
"工程支持通过 NFC 绑定并学习红外外设;当前没有同时满足“已学习、已启用且保留有效 NFC 绑定”的可执行外设能力。",
"peripherals",
)
labels = [
@@ -2321,7 +2321,9 @@ class AssistantCapabilityCatalog:
try:
nfc = dict(self._nfc_provider()) if self._nfc_provider else {}
except Exception: # noqa: BLE001 - optional NFC reader may be offline
return [], "unavailable"
# The reader only reports current presence. Durable bindings remain
# callable even while the reader is offline or no card is present.
nfc = {}
card = nfc.get("card") if isinstance(nfc.get("card"), Mapping) else {}
active_uid = _normalized_nfc_uid(card.get("uid")) if isinstance(card, Mapping) else ""
peripherals: list[dict[str, object]] = []
@@ -2350,7 +2352,7 @@ class AssistantCapabilityCatalog:
active_uid and card_uid and active_uid == card_uid
),
"assistant_callable": bool(
learned and enabled and active_uid and card_uid and active_uid == card_uid
learned and enabled and card_uid
),
}
)
@@ -0,0 +1,638 @@
"""Receive, authorize and execute car-originated infrared control requests."""
from __future__ import annotations
import json
import math
import queue
import re
import threading
import time
import uuid
from collections.abc import Callable, Mapping
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
from cognitive_agent.application.extension_toy_publisher import (
normalize_extension_nfc_id,
)
EXTENSION_CONTROL_TOPIC = "extension/ctl"
MAX_EXTENSION_CONTROL_ACTIONS = 64
MAX_EXTENSION_CONTROL_AT_MS = 60_000
MAX_EXTENSION_CONTROL_PAYLOAD_BYTES = 256 * 1024
DEFAULT_EXTENSION_CONTROL_QUEUE_SIZE = 32
DEFAULT_ADJACENT_EMIT_INTERVAL_S = 0.8
_ACTION_REQUIRED_KEYS = frozenset(
{
"type",
"at_ms",
"domain",
"capability",
"target_scope",
"operation",
"parameters",
}
)
_DEVICE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
class ExtensionControlError(ValueError):
"""Base error for an invalid or unauthorized ``extension/ctl`` request."""
class ExtensionControlAuthorizationError(ExtensionControlError):
"""Raised when an action is absent from the local durable allowlist."""
def _timestamp() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _json_object_without_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise ExtensionControlError(f"extension/ctl contains duplicate key: {key}")
result[key] = value
return result
def parse_extension_control_payload(raw_payload: bytes | str) -> dict[str, Any]:
"""Parse the fixed car-to-PC control envelope and normalize its actions."""
if isinstance(raw_payload, bytes):
encoded = raw_payload
try:
text = raw_payload.decode("utf-8")
except UnicodeDecodeError as exc:
raise ExtensionControlError("extension/ctl payload must be UTF-8 JSON") from exc
elif isinstance(raw_payload, str):
text = raw_payload
encoded = raw_payload.encode("utf-8")
else:
raise ExtensionControlError("extension/ctl payload must be UTF-8 JSON")
if not encoded or len(encoded) > MAX_EXTENSION_CONTROL_PAYLOAD_BYTES:
raise ExtensionControlError("extension/ctl payload must contain 1-256 KiB")
try:
payload = json.loads(text, object_pairs_hook=_json_object_without_duplicate_keys)
except ExtensionControlError:
raise
except (json.JSONDecodeError, TypeError, ValueError) as exc:
raise ExtensionControlError("extension/ctl payload must be valid JSON") from exc
if not isinstance(payload, Mapping) or set(payload) != {
"domain",
"operation_count",
"actions",
}:
raise ExtensionControlError(
"extension/ctl requires exactly domain, operation_count and actions"
)
if payload.get("domain") != "self_extension":
raise ExtensionControlError("extension/ctl domain must be self_extension")
operation_count = payload.get("operation_count")
actions = payload.get("actions")
if (
isinstance(operation_count, bool)
or not isinstance(operation_count, int)
or not 1 <= operation_count <= MAX_EXTENSION_CONTROL_ACTIONS
):
raise ExtensionControlError(
f"extension/ctl operation_count must be in range 1-{MAX_EXTENSION_CONTROL_ACTIONS}"
)
if not isinstance(actions, list) or len(actions) != operation_count:
raise ExtensionControlError(
"extension/ctl operation_count must equal the actions length"
)
normalized_actions: list[dict[str, Any]] = []
for index, raw_action in enumerate(actions):
if not isinstance(raw_action, Mapping):
raise ExtensionControlError(f"extension/ctl action {index} must be an object")
missing = sorted(_ACTION_REQUIRED_KEYS - set(raw_action))
if missing:
raise ExtensionControlError(
f"extension/ctl action {index} is missing: {', '.join(missing)}"
)
if raw_action.get("type") != "infrared_emit":
raise ExtensionControlError(
f"extension/ctl action {index} type must be infrared_emit"
)
at_ms = raw_action.get("at_ms")
if (
isinstance(at_ms, bool)
or not isinstance(at_ms, int)
or not 0 <= at_ms <= MAX_EXTENSION_CONTROL_AT_MS
):
raise ExtensionControlError(
f"extension/ctl action {index} at_ms must be in range "
f"0-{MAX_EXTENSION_CONTROL_AT_MS}"
)
if raw_action.get("domain") != "self_extension":
raise ExtensionControlError(
f"extension/ctl action {index} domain must be self_extension"
)
if raw_action.get("capability") != "infrared.emit":
raise ExtensionControlError(
f"extension/ctl action {index} capability must be infrared.emit"
)
if raw_action.get("operation") != "emit":
raise ExtensionControlError(
f"extension/ctl action {index} operation must be emit"
)
target_scope = raw_action.get("target_scope")
if not isinstance(target_scope, str) or not _DEVICE_ID_PATTERN.fullmatch(
target_scope
):
raise ExtensionControlError(
f"extension/ctl action {index} target_scope must be an English device id"
)
parameters = raw_action.get("parameters")
if not isinstance(parameters, Mapping) or set(parameters) != {"action", "slot"}:
raise ExtensionControlError(
f"extension/ctl action {index} parameters require exactly action and slot"
)
action_name = parameters.get("action")
slot = parameters.get("slot")
if (
not isinstance(action_name, str)
or not action_name.strip()
or len(action_name.strip()) > 48
):
raise ExtensionControlError(
f"extension/ctl action {index} action name must contain 1-48 characters"
)
if isinstance(slot, bool) or not isinstance(slot, int) or not 1 <= slot <= 248:
raise ExtensionControlError(
f"extension/ctl action {index} slot must be in range 1-248"
)
normalized_actions.append(
{
"type": "infrared_emit",
"at_ms": at_ms,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": target_scope,
"operation": "emit",
"parameters": {"action": action_name.strip(), "slot": slot},
}
)
return {
"domain": "self_extension",
"operation_count": operation_count,
"actions": normalized_actions,
}
def authorize_extension_control_actions(
request: Mapping[str, Any],
catalog: object,
) -> list[dict[str, Any]]:
"""Authorize every action before any TCP command may be sent."""
actions = request.get("actions")
if not isinstance(actions, list):
raise ExtensionControlError("extension/ctl actions must be an array")
if not isinstance(catalog, list):
raise ExtensionControlError("local extension capability catalog is unavailable")
allowlist: dict[tuple[str, int, str], dict[str, Any]] = {}
for capability in catalog:
if (
not isinstance(capability, Mapping)
or capability.get("status") != "learned"
or capability.get("enabled") is not True
or not normalize_extension_nfc_id(capability.get("card_uid"))
):
continue
device_id = str(capability.get("device_id") or "").strip()
action_name = str(capability.get("action") or "").strip()
slot = capability.get("slot")
if (
not _DEVICE_ID_PATTERN.fullmatch(device_id)
or not action_name
or isinstance(slot, bool)
or not isinstance(slot, int)
or not 1 <= slot <= 248
):
continue
identity = (device_id, slot, action_name)
current = allowlist.get(identity)
if current is None or str(capability.get("id") or "") < str(
current.get("id") or ""
):
allowlist[identity] = dict(capability)
authorized: list[dict[str, Any]] = []
for index, action in enumerate(actions):
parameters = action.get("parameters") if isinstance(action, Mapping) else None
if not isinstance(parameters, Mapping):
raise ExtensionControlError(f"extension/ctl action {index} is invalid")
target_scope = str(action.get("target_scope") or "").strip()
action_name = str(parameters.get("action") or "").strip()
slot = parameters.get("slot")
identity = (target_scope, slot, action_name)
capability = allowlist.get(identity)
if capability is None:
raise ExtensionControlAuthorizationError(
"extension/ctl action is not authorized by the local learned allowlist: "
f"{target_scope} / {action_name} / IR {slot}"
)
authorized.append(
{
"index": index,
"at_ms": int(action.get("at_ms", 0)),
"action": deepcopy(dict(action)),
"capability_id": str(capability.get("id") or ""),
"extension_name": str(capability.get("device_name") or ""),
}
)
return authorized
class ExtensionControlConsumer:
"""Serialize validated car commands onto the computer's infrared TCP gateway."""
def __init__(
self,
*,
catalog_provider: Callable[[], list[dict[str, Any]]],
executor: Callable[[Mapping[str, Any]], Mapping[str, Any]],
queue_size: int = DEFAULT_EXTENSION_CONTROL_QUEUE_SIZE,
adjacent_emit_interval_s: float = DEFAULT_ADJACENT_EMIT_INTERVAL_S,
) -> None:
if queue_size <= 0:
raise ValueError("queue_size must be greater than zero")
if adjacent_emit_interval_s < 0:
raise ValueError("adjacent_emit_interval_s must not be negative")
self._catalog_provider = catalog_provider
self._executor = executor
self._queue: queue.Queue[tuple[bytes, float, str]] = queue.Queue(queue_size)
self._adjacent_emit_interval_s = float(adjacent_emit_interval_s)
self._lock = threading.RLock()
self._changed = threading.Condition(self._lock)
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._revision = 0
self._status: dict[str, Any] = {
"state": "stopped",
"topic": EXTENSION_CONTROL_TOPIC,
"source": "vehicle_mqtt_extension",
"source_label": "车端 MQTT",
"request_count": 0,
"processed_count": 0,
"rejected_count": 0,
"failed_count": 0,
"executed_action_count": 0,
"last_request_id": None,
"last_received_at": None,
"last_started_at": None,
"last_completed_at": None,
"last_latency_ms": None,
"last_request": None,
"action_statuses": [],
"active_action_index": None,
"last_rejection_kind": None,
"last_results": [],
"last_error": None,
}
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._set_status(state="waiting", last_error=None)
self._thread = threading.Thread(
target=self._run,
name="n7-extension-control-consumer",
daemon=True,
)
self._thread.start()
def close(self) -> None:
self._stop.set()
thread = self._thread
if thread and thread.is_alive():
thread.join(timeout=2.0)
self._thread = None
self._set_status(state="stopped")
def submit(self, raw_payload: bytes) -> None:
"""Queue raw broker bytes without blocking the MQTT network loop."""
if self._stop.is_set():
raise RuntimeError("extension control consumer is stopped")
payload = bytes(raw_payload)
if not payload or len(payload) > MAX_EXTENSION_CONTROL_PAYLOAD_BYTES:
raise ExtensionControlError("extension/ctl payload must contain 1-256 KiB")
received_at = _timestamp()
try:
self._queue.put_nowait((payload, time.monotonic(), received_at))
except queue.Full as exc:
self._set_status(state="queue_full", last_error="extension/ctl queue is full")
raise RuntimeError("extension/ctl queue is full") from exc
with self._lock:
request_count = int(self._status["request_count"]) + 1
current_state = str(self._status["state"])
self._set_status(
state="queued" if current_state not in {"validating", "executing"} else current_state,
request_count=request_count,
last_received_at=received_at,
last_error=None,
)
def snapshot(self) -> dict[str, Any]:
with self._lock:
return self._snapshot_locked()
def reject_transport_message(
self,
reason: str,
rejection_kind: str = "transport",
) -> None:
"""Project an MQTT-layer rejection without executing any action."""
error = str(reason or "extension/ctl message was rejected").strip()[:240]
kind = str(rejection_kind or "transport").strip()[:48]
timestamp = _timestamp()
with self._lock:
request_count = int(self._status["request_count"]) + 1
processed_count = int(self._status["processed_count"]) + 1
rejected_count = int(self._status["rejected_count"]) + 1
self._set_status(
state="rejected",
request_count=request_count,
processed_count=processed_count,
rejected_count=rejected_count,
last_request_id=f"extension-ctl-{uuid.uuid4().hex}",
last_received_at=timestamp,
last_started_at=timestamp,
last_completed_at=timestamp,
last_latency_ms=0,
last_request=None,
action_statuses=[],
active_action_index=None,
last_rejection_kind=kind,
last_results=[],
last_error=error,
)
def wait_for_snapshot(
self,
after_revision: int,
*,
timeout_s: float = 25.0,
) -> dict[str, Any]:
"""Long-poll until the display projection changes or the timeout expires."""
if isinstance(after_revision, bool) or not isinstance(after_revision, int):
raise ValueError("after_revision must be an integer")
if after_revision < -1:
raise ValueError("after_revision must be at least -1")
if (
isinstance(timeout_s, bool)
or not isinstance(timeout_s, (int, float))
or not math.isfinite(float(timeout_s))
or not 0 <= float(timeout_s) <= 30
):
raise ValueError("timeout_s must be between 0 and 30 seconds")
with self._changed:
self._changed.wait_for(
lambda: self._revision > after_revision or self._stop.is_set(),
timeout=float(timeout_s),
)
return self._snapshot_locked()
def _snapshot_locked(self) -> dict[str, Any]:
return {
"revision": self._revision,
**deepcopy(self._status),
"queue_depth": self._queue.qsize(),
"queue_capacity": self._queue.maxsize,
}
def _set_status(self, **changes: Any) -> None:
with self._changed:
next_status = {**self._status, **changes}
if next_status == self._status:
return
self._status = next_status
self._revision += 1
self._changed.notify_all()
def _record_rejection(
self,
error: Exception,
*,
request: Mapping[str, Any] | None = None,
rejection_kind: str = "validation",
) -> None:
with self._lock:
processed = int(self._status["processed_count"]) + 1
rejected = int(self._status["rejected_count"]) + 1
self._set_status(
state="rejected",
processed_count=processed,
rejected_count=rejected,
last_completed_at=_timestamp(),
last_request=deepcopy(dict(request)) if request is not None else None,
action_statuses=[],
active_action_index=None,
last_rejection_kind=rejection_kind,
last_results=[],
last_error=str(error)[:240],
)
def _process(self, item: tuple[bytes, float, str]) -> None:
raw_payload, received_monotonic, received_at = item
request_id = f"extension-ctl-{uuid.uuid4().hex}"
self._set_status(
state="validating",
last_request_id=request_id,
last_received_at=received_at,
last_started_at=_timestamp(),
last_completed_at=None,
last_latency_ms=None,
last_request=None,
action_statuses=[],
active_action_index=None,
last_rejection_kind=None,
last_results=[],
last_error=None,
)
request: dict[str, Any] | None = None
try:
request = parse_extension_control_payload(raw_payload)
actions = authorize_extension_control_actions(
request,
self._catalog_provider(),
)
except Exception as exc: # noqa: BLE001 - reject complete request atomically
self._record_rejection(exc, request=request)
return
ordered_actions = sorted(actions, key=lambda item: (item["at_ms"], item["index"]))
action_statuses = [
{
"index": authorized["index"],
"execution_order": execution_order,
"at_ms": authorized["at_ms"],
"capability_id": authorized["capability_id"],
"extension_name": authorized["extension_name"],
"target_scope": authorized["action"]["target_scope"],
"action": authorized["action"]["parameters"]["action"],
"slot": authorized["action"]["parameters"]["slot"],
"state": "pending",
"state_label": "等待下发",
"state_detail": f"计划在接收后 {authorized['at_ms']} ms 下发",
"transport": "tcp",
"gateway_acknowledged": False,
"physical_state_confirmed": False,
"started_at": None,
"completed_at": None,
}
for execution_order, authorized in enumerate(ordered_actions)
]
self._set_status(
state="executing",
last_request_id=request_id,
last_request=deepcopy(request),
action_statuses=deepcopy(action_statuses),
)
results: list[dict[str, Any]] = []
previous_emit_monotonic: float | None = None
failure: Exception | None = None
for item_index, authorized in enumerate(ordered_actions):
due_at = received_monotonic + authorized["at_ms"] / 1_000
if previous_emit_monotonic is not None:
due_at = max(
due_at,
previous_emit_monotonic + self._adjacent_emit_interval_s,
)
if self._stop.wait(max(0.0, due_at - time.monotonic())):
failure = RuntimeError("extension control consumer stopped")
action_statuses[item_index].update(
state="failed",
state_label="下发失败",
state_detail=str(failure),
completed_at=_timestamp(),
)
break
action_statuses[item_index].update(
state="executing",
state_label="正在下发",
state_detail="正在通过本机 TCP 红外网关下发",
started_at=_timestamp(),
)
self._set_status(
state="executing",
active_action_index=authorized["index"],
action_statuses=deepcopy(action_statuses),
)
try:
result = self._executor(deepcopy(authorized["action"]))
if not isinstance(result, Mapping) or result.get("success") is False:
raise RuntimeError("infrared TCP gateway did not accept the command")
except Exception as exc: # noqa: BLE001 - abort remaining control flow
failure = exc
action_statuses[item_index].update(
state="failed",
state_label="下发失败",
state_detail=str(exc)[:240],
completed_at=_timestamp(),
)
break
previous_emit_monotonic = time.monotonic()
gateway_acknowledged = result.get("gateway_acknowledged") is True
physical_state_confirmed = result.get("physical_state_confirmed") is True
action_statuses[item_index].update(
state=(
"verified_success"
if physical_state_confirmed
else "submitted_unverified"
),
state_label=(
"执行已确认"
if physical_state_confirmed
else "已下发 · 物理效果未确认"
),
state_detail=(
"设备物理效果已确认"
if physical_state_confirmed
else "网关已确认;物理效果未确认"
if gateway_acknowledged
else "TCP 已提交;设备未返回 ACK,物理效果未确认"
),
transport=str(result.get("transport") or "tcp"),
gateway_acknowledged=gateway_acknowledged,
physical_state_confirmed=physical_state_confirmed,
completed_at=_timestamp(),
)
results.append(
{
"index": authorized["index"],
"at_ms": authorized["at_ms"],
"capability_id": authorized["capability_id"],
"extension_name": authorized["extension_name"],
"target_scope": authorized["action"]["target_scope"],
"action": authorized["action"]["parameters"]["action"],
"slot": authorized["action"]["parameters"]["slot"],
"transport": str(result.get("transport") or "tcp"),
"gateway_acknowledged": gateway_acknowledged,
"physical_state_confirmed": physical_state_confirmed,
"executed_at": _timestamp(),
"execution_order": item_index,
}
)
self._set_status(
action_statuses=deepcopy(action_statuses),
last_results=deepcopy(results),
)
if failure is not None:
for pending in action_statuses:
if pending["state"] == "pending":
pending.update(
state="aborted",
state_label="已中止",
state_detail="前序红外指令失败,未执行",
completed_at=_timestamp(),
)
completed_at = _timestamp()
latency_ms = round(max(0.0, time.monotonic() - received_monotonic) * 1_000)
with self._lock:
processed = int(self._status["processed_count"]) + 1
failed = int(self._status["failed_count"]) + (1 if failure else 0)
executed = int(self._status["executed_action_count"]) + len(results)
self._set_status(
state="failed" if failure else "completed",
processed_count=processed,
failed_count=failed,
executed_action_count=executed,
active_action_index=None,
action_statuses=deepcopy(action_statuses),
last_completed_at=completed_at,
last_latency_ms=latency_ms,
last_results=deepcopy(results),
last_error=str(failure)[:240] if failure else None,
)
def _run(self) -> None:
while not self._stop.is_set():
try:
item = self._queue.get(timeout=0.1)
except queue.Empty:
continue
try:
self._process(item)
except Exception as exc: # noqa: BLE001 - keep the consumer alive
self._record_rejection(exc)
finally:
self._queue.task_done()
@@ -0,0 +1,381 @@
"""Bounded MQTT capability announcements for NFC-bound infrared extensions."""
from __future__ import annotations
import re
import threading
import time
from collections.abc import Callable, Mapping
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
EXTENSION_TOY_MQTT_TOPIC = "extension/toy"
EXTENSION_TOY_BURST_COUNT = 3
EXTENSION_TOY_BURST_WINDOW_S = 1.0
_DEVICE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_NFC_ID_PATTERN = re.compile(r"^[0-9A-F]{4,64}$")
def _timestamp() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def normalize_extension_nfc_id(value: Any) -> str:
"""Return an uppercase hexadecimal NFC UID, or an empty string if invalid."""
raw_value = str(value or "").strip()
if not raw_value or re.search(r"[^0-9a-f\s:-]", raw_value, flags=re.IGNORECASE):
return ""
normalized = re.sub(r"[\s:-]", "", raw_value).upper()
return normalized if _NFC_ID_PATTERN.fullmatch(normalized) else ""
def _extension_display_name(capability: Mapping[str, Any]) -> str:
"""Prefer the catalog's Chinese display identity for ``extension_name``."""
candidates: list[str] = [str(capability.get("device_name") or "").strip()]
for field in ("object_names", "device_aliases"):
values = capability.get(field)
if isinstance(values, str):
candidates.extend(re.split(r"[,,、;;|]", values))
elif isinstance(values, list):
candidates.extend(str(value) for value in values)
normalized = [candidate.strip() for candidate in candidates if candidate.strip()]
chinese = next(
(candidate for candidate in normalized if re.search(r"[\u4e00-\u9fff]", candidate)),
None,
)
return str(chinese or (normalized[0] if normalized else ""))[:64]
def build_extension_toy_messages(
card_uid: Any,
catalog: object,
) -> list[dict[str, Any]]:
"""Project enabled learned abilities bound to one detected NFC card.
One message is returned per stable English ``device_id``. Within a device,
actions are ordered by infrared slot and exact duplicate abilities are
removed. No action is executed by this projection.
"""
normalized_uid = normalize_extension_nfc_id(card_uid)
if not normalized_uid:
return []
if not isinstance(catalog, list):
raise ValueError("extension capability catalog must be an array")
candidates: list[tuple[str, int, str, str]] = []
seen: set[tuple[str, int, str]] = set()
for item in catalog:
if (
not isinstance(item, Mapping)
or item.get("status") != "learned"
or item.get("enabled") is not True
or normalize_extension_nfc_id(item.get("card_uid")) != normalized_uid
):
continue
device_id = str(item.get("device_id") or "").strip()
extension_name = _extension_display_name(item)
action_name = str(item.get("action") or "").strip()
raw_slot = item.get("slot")
if (
not _DEVICE_ID_PATTERN.fullmatch(device_id)
or not extension_name
or not action_name
or len(action_name) > 48
or isinstance(raw_slot, bool)
or not isinstance(raw_slot, int)
or not 1 <= raw_slot <= 248
):
continue
identity = (device_id, raw_slot, action_name)
if identity in seen:
continue
seen.add(identity)
candidates.append((device_id, raw_slot, action_name, extension_name))
grouped: dict[str, dict[str, Any]] = {}
for device_id, slot, action_name, extension_name in sorted(
candidates,
key=lambda item: (item[0], item[1], item[2]),
):
message = grouped.setdefault(
device_id,
{"extension_name": extension_name, "actions": []},
)
message["actions"].append(
{
"type": "infrared_emit",
"at_ms": 0,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": device_id,
"operation": "emit",
"parameters": {"action": action_name, "slot": slot},
}
)
return [deepcopy(grouped[device_id]) for device_id in sorted(grouped)]
class NfcExtensionToyPublisher:
"""Publish one three-frame ability burst for each NFC card-detection edge."""
def __init__(
self,
*,
nfc_provider: Callable[[], Mapping[str, Any]],
catalog_provider: Callable[[], list[dict[str, Any]]],
publisher: Callable[[Mapping[str, Any]], Mapping[str, Any]],
burst_count: int = EXTENSION_TOY_BURST_COUNT,
burst_window_s: float = EXTENSION_TOY_BURST_WINDOW_S,
poll_interval_s: float = 0.1,
) -> None:
if isinstance(burst_count, bool) or not isinstance(burst_count, int):
raise ValueError("burst_count must be an integer")
if burst_count <= 0:
raise ValueError("burst_count must be greater than zero")
if burst_window_s <= 0:
raise ValueError("burst_window_s must be greater than zero")
if poll_interval_s <= 0:
raise ValueError("poll_interval_s must be greater than zero")
self.burst_count = burst_count
self.burst_window_s = float(burst_window_s)
self.burst_interval_s = self.burst_window_s / self.burst_count
self.poll_interval_s = float(poll_interval_s)
self._nfc_provider = nfc_provider
self._catalog_provider = catalog_provider
self._publisher = publisher
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._revision = 0
self._status: dict[str, Any] = {
"state": "stopped",
"topic": EXTENSION_TOY_MQTT_TOPIC,
"qos": 1,
"retain": False,
"burst_count": self.burst_count,
"burst_window_ms": round(self.burst_window_s * 1_000),
"active_nfc_id": None,
"active_extension_names": [],
"active_target_scopes": [],
"message_count": 0,
"burst_frame_attempt_count": 0,
"burst_publish_attempt_count": 0,
"burst_published_count": 0,
"total_publish_count": 0,
"last_detection_at": None,
"last_burst_started_at": None,
"last_burst_completed_at": None,
"last_published_at": None,
"last_delivery_status": None,
"last_message_id": None,
"last_messages": [],
"last_error": None,
}
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._set_status(
state="waiting_for_nfc",
active_nfc_id=None,
active_extension_names=[],
active_target_scopes=[],
message_count=0,
last_error=None,
)
self._thread = threading.Thread(
target=self._run,
name="n7-extension-toy-mqtt-publisher",
daemon=True,
)
self._thread.start()
def close(self) -> None:
self._stop.set()
thread = self._thread
if thread and thread.is_alive():
thread.join(timeout=2.0)
self._thread = None
self._set_status(
state="stopped",
active_nfc_id=None,
active_extension_names=[],
active_target_scopes=[],
message_count=0,
)
def snapshot(self) -> dict[str, Any]:
with self._lock:
return {"revision": self._revision, **deepcopy(self._status)}
def _set_status(self, **changes: Any) -> None:
with self._lock:
next_status = {**self._status, **changes}
if next_status == self._status:
return
self._status = next_status
self._revision += 1
def _active_nfc_id(self) -> str:
snapshot = self._nfc_provider()
if not isinstance(snapshot, Mapping):
raise TypeError("NFC provider returned an invalid snapshot")
card = snapshot.get("card")
if not isinstance(card, Mapping):
return ""
if str(card.get("action") or "present") != "present":
return ""
return normalize_extension_nfc_id(card.get("uid"))
def _publish_burst(
self,
card_uid: str,
messages: list[dict[str, Any]],
) -> None:
started = time.monotonic()
publish_attempts = 0
published = 0
last_delivery_status: str | None = None
last_message_id: Any = None
last_error: str | None = None
with self._lock:
total_publish_count = int(self._status["total_publish_count"])
self._set_status(
state="bursting",
active_nfc_id=card_uid,
active_extension_names=[message["extension_name"] for message in messages],
active_target_scopes=[
message["actions"][0]["target_scope"] for message in messages
],
message_count=len(messages),
burst_frame_attempt_count=0,
burst_publish_attempt_count=0,
burst_published_count=0,
last_detection_at=_timestamp(),
last_burst_started_at=_timestamp(),
last_burst_completed_at=None,
last_delivery_status=None,
last_message_id=None,
last_messages=deepcopy(messages),
last_error=None,
)
completed_frames = 0
for frame_index in range(self.burst_count):
due_at = started + frame_index * self.burst_interval_s
if self._stop.wait(max(0.0, due_at - time.monotonic())):
break
completed_frames += 1
for message in messages:
publish_attempts += 1
try:
result = self._publisher(deepcopy(message))
if not isinstance(result, Mapping):
raise TypeError("extension/toy publisher returned an invalid result")
except Exception as exc: # noqa: BLE001 - finish the bounded burst
last_error = str(exc)[:240]
last_delivery_status = "failed"
else:
published += 1
total_publish_count += 1
last_delivery_status = str(
result.get("delivery_status") or "queued"
)
last_message_id = result.get("message_id")
self._set_status(last_published_at=_timestamp())
self._set_status(
state="bursting",
burst_frame_attempt_count=completed_frames,
burst_publish_attempt_count=publish_attempts,
burst_published_count=published,
total_publish_count=total_publish_count,
last_delivery_status=last_delivery_status,
last_message_id=last_message_id,
last_error=last_error,
)
expected_publications = self.burst_count * len(messages)
completed = completed_frames == self.burst_count
if completed and published != expected_publications:
last_delivery_status = "partial" if published else "failed"
self._set_status(
state=(
"stopped"
if self._stop.is_set() and not completed
else "burst_complete"
if published == expected_publications
else "burst_failed"
),
burst_frame_attempt_count=completed_frames,
burst_publish_attempt_count=publish_attempts,
burst_published_count=published,
total_publish_count=total_publish_count,
last_delivery_status=last_delivery_status,
last_message_id=last_message_id,
last_burst_completed_at=_timestamp() if completed else None,
last_error=last_error,
)
def _run(self) -> None:
latched_uid = ""
while not self._stop.is_set():
try:
card_uid = self._active_nfc_id()
except Exception as exc: # noqa: BLE001 - report and continue polling NFC
self._set_status(state="nfc_unavailable", last_error=str(exc)[:240])
self._stop.wait(self.poll_interval_s)
continue
if card_uid != latched_uid:
if not card_uid:
latched_uid = ""
self._set_status(
state="waiting_for_nfc",
active_nfc_id=None,
active_extension_names=[],
active_target_scopes=[],
message_count=0,
burst_frame_attempt_count=0,
burst_publish_attempt_count=0,
burst_published_count=0,
last_error=None,
)
else:
try:
catalog = self._catalog_provider()
messages = build_extension_toy_messages(card_uid, catalog)
except Exception as exc: # noqa: BLE001 - retry while detection remains
self._set_status(
state="catalog_unavailable",
active_nfc_id=card_uid,
last_error=str(exc)[:240],
)
else:
# Latch only after a successful catalog read. A card that
# remains present never starts a second burst.
latched_uid = card_uid
if messages:
self._publish_burst(card_uid, messages)
else:
self._set_status(
state="no_matching_extension",
active_nfc_id=card_uid,
active_extension_names=[],
active_target_scopes=[],
message_count=0,
burst_frame_attempt_count=0,
burst_publish_attempt_count=0,
burst_published_count=0,
last_detection_at=_timestamp(),
last_messages=[],
last_error=None,
)
self._stop.wait(self.poll_interval_s)
@@ -6,6 +6,9 @@ import hashlib
import json
import os
import threading
import time
from collections import Counter
from collections.abc import Callable, Mapping
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
@@ -21,6 +24,8 @@ N7_SCENE_SERVICE = "n7-scenes"
N7_SCENE_SCHEMA_VERSION = 1
DEFAULT_N7_SCENE_SERVER_ID = "n7-cognitive-agent"
DEFAULT_N7_SCENE_SERVER_NAME = "N7 模式卡片目录"
MODE_SCENE_MQTT_BURST_COUNT = 3
MODE_SCENE_MQTT_BURST_WINDOW_S = 1.0
def _utc_now() -> str:
@@ -65,7 +70,12 @@ def _legacy_zone(target_scope: str) -> str:
}.get(target_scope, target_scope)
def _scene_action(operation: ModeOperation) -> dict[str, Any]:
def _scene_action(
operation: ModeOperation,
*,
target: Mapping[str, Any] | None = None,
operation_ref: str = "",
) -> dict[str, Any]:
"""Preserve the complete operation and add fields understood by the v1 demo."""
parameters = deepcopy(operation.parameters)
@@ -78,6 +88,14 @@ def _scene_action(operation: ModeOperation) -> dict[str, Any]:
"operation": operation.operation,
"parameters": parameters,
}
if target:
# FunctionConfig targets carry details which cannot be represented by
# the legacy ``target_scope`` string (notably an infrared device and
# its persisted NFC card UID). Keep that compact target object in the
# public scene action without exposing the complete command flow.
action["target"] = deepcopy(dict(target))
if operation_ref:
action["operation_ref"] = operation_ref
# The original demo catalog flattened these three climate actions. Keep
# those aliases so an existing v1 client can continue reading them, while
@@ -100,11 +118,130 @@ def _scene_action(operation: ModeOperation) -> dict[str, Any]:
return action
def _operation_verb(capability: str) -> str:
action = capability.partition(".")[2].strip()
return action.partition("_")[0] or "set"
def _operation_identity(operation: ModeOperation) -> str:
"""Return a stable semantic key across profile and FunctionConfig shapes."""
target_scope = {
"driver": "front_left",
"passenger": "front_right",
}.get(operation.target_scope, operation.target_scope)
return json.dumps(
{
"domain": operation.domain,
"capability": operation.capability,
"target_scope": target_scope,
"parameters": operation.parameters,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
def _command_flow_operations(
profile: ModeProfile,
) -> list[tuple[ModeOperation, dict[str, Any], str]]:
"""Project the latest saved FunctionConfigs into scene operations.
The top-level ``profile.operations`` list is retained for compatibility
with mode matching and native CAN execution. A saved editor flow can also
contain executable self-extension operations, so the client-facing scene
projection must read the complete FunctionConfig set used by the editor.
Malformed optional flow entries are ignored and later filled from the
canonical top-level operations.
"""
command_flow = profile.command_flow
if not isinstance(command_flow, dict):
return []
configurations = command_flow.get("function_configs")
if not isinstance(configurations, dict):
return []
projected: list[tuple[ModeOperation, dict[str, Any], str]] = []
for configuration in configurations.values():
if not isinstance(configuration, dict):
continue
spec = configuration.get("spec")
if not isinstance(spec, dict):
continue
raw_operations = spec.get("operations")
if not isinstance(raw_operations, list):
continue
configured_domain = str(spec.get("domain") or "").strip()
for raw_operation in raw_operations:
if not isinstance(raw_operation, dict):
continue
capability = str(raw_operation.get("capability") or "").strip()
if not capability:
continue
domain = str(
raw_operation.get("domain")
or configured_domain
or capability.partition(".")[0]
).strip()
raw_parameters = raw_operation.get("parameters") or {}
raw_target = raw_operation.get("target") or {}
if not domain or not isinstance(raw_parameters, dict):
continue
target = dict(raw_target) if isinstance(raw_target, dict) else {}
target_scope = str(
raw_operation.get("target_scope")
or target.get("zone")
or target.get("scope")
or target.get("device")
or "vehicle"
).strip()
operation = ModeOperation(
domain=domain,
capability=capability,
target_scope=target_scope,
operation=str(
raw_operation.get("operation") or _operation_verb(capability)
).strip(),
parameters=deepcopy(raw_parameters),
reason=str(raw_operation.get("reason") or ""),
)
projected.append(
(operation, target, str(raw_operation.get("id") or "").strip())
)
return projected
def _effective_scene_operations(
profile: ModeProfile,
) -> list[tuple[ModeOperation, dict[str, Any], str]]:
"""Use the latest editor flow first, then fill any non-duplicated fallback."""
effective = _command_flow_operations(profile)
if not effective:
return [(operation, {}, "") for operation in profile.operations]
# Keep the authoritative flow exactly as ordered, including intentional
# repeated steps. Top-level operations only fill semantic occurrences
# absent from that flow, so the two persistence shapes are not double-counted.
represented = Counter(_operation_identity(operation) for operation, _, _ in effective)
profile_occurrences: Counter[str] = Counter()
for operation in profile.operations:
identity = _operation_identity(operation)
profile_occurrences[identity] += 1
if profile_occurrences[identity] <= represented[identity]:
continue
effective.append((operation, {}, ""))
return effective
def _scene_from_profile(
registry: ModeProfileRegistry,
profile: ModeProfile,
public_profile: dict[str, Any],
) -> dict[str, Any]:
effective_operations = _effective_scene_operations(profile)
scene = {
"id": profile.id,
"label": profile.name,
@@ -116,11 +253,18 @@ def _scene_from_profile(
"origin": public_profile.get("origin", "preset"),
"deletable": bool(public_profile.get("deletable", False)),
"behavior": public_profile.get("behavior", "static"),
"domains": list(public_profile.get("domains") or []),
"operation_count": len(profile.operations),
"domains": sorted({operation.domain for operation, _, _ in effective_operations}),
"operation_count": len(effective_operations),
"scope": deepcopy(public_profile.get("scope") or {}),
"profile_revision": registry.profile_revision(profile),
"actions": [_scene_action(operation) for operation in profile.operations],
"actions": [
_scene_action(
operation,
target=target,
operation_ref=operation_ref,
)
for operation, target, operation_ref in effective_operations
],
}
if public_profile.get("preset_override") is True:
scene["preset_override"] = True
@@ -246,3 +390,177 @@ class ModeSceneCatalogPublisher:
except (OSError, json.JSONDecodeError):
return None
return document if isinstance(document, dict) else None
class ModeSceneMqttBurstPublisher:
"""Publish one newly saved scene three times without blocking its HTTP save."""
def __init__(
self,
publisher: Callable[[Mapping[str, Any]], Mapping[str, Any]],
*,
topic: str,
burst_count: int = MODE_SCENE_MQTT_BURST_COUNT,
burst_window_s: float = MODE_SCENE_MQTT_BURST_WINDOW_S,
) -> None:
if isinstance(burst_count, bool) or not isinstance(burst_count, int):
raise ValueError("burst_count must be an integer")
if burst_count <= 0:
raise ValueError("burst_count must be greater than zero")
if burst_window_s <= 0:
raise ValueError("burst_window_s must be greater than zero")
clean_topic = str(topic).strip()
if not clean_topic:
raise ValueError("topic must not be empty")
self._publisher = publisher
self.topic = clean_topic
self.burst_count = burst_count
self.burst_window_s = float(burst_window_s)
self.burst_interval_s = self.burst_window_s / self.burst_count
self._lock = threading.RLock()
self._stop = threading.Event()
self._threads: set[threading.Thread] = set()
self._status: dict[str, Any] = {
"state": "idle",
"topic": self.topic,
"scene_id": None,
"burst_count": self.burst_count,
"burst_window_ms": round(self.burst_window_s * 1_000),
"burst_attempt_count": 0,
"burst_published_count": 0,
"last_delivery_status": None,
"last_message_id": None,
"last_error": None,
"last_burst_started_at": None,
"last_burst_completed_at": None,
}
def queue(self, scene: Mapping[str, Any]) -> dict[str, Any]:
"""Queue an immutable copy of exactly one scene-card object."""
if not isinstance(scene, Mapping):
raise ValueError("scene must be an object")
payload = deepcopy(dict(scene))
scene_id = str(payload.get("id") or "").strip()
if not scene_id:
raise ValueError("scene id must not be empty")
if "scenes" in payload or "service" in payload:
raise ValueError("scene burst accepts one scene card, not a catalog")
if self._stop.is_set():
raise RuntimeError("mode scene MQTT publisher is stopped")
thread = threading.Thread(
target=self._run_burst,
args=(payload,),
name=f"n7-mode-scene-mqtt-{scene_id}",
daemon=True,
)
with self._lock:
self._threads.add(thread)
self._status.update(
state="queued",
scene_id=scene_id,
burst_attempt_count=0,
burst_published_count=0,
last_delivery_status=None,
last_message_id=None,
last_error=None,
last_burst_started_at=None,
last_burst_completed_at=None,
)
try:
thread.start()
except Exception:
with self._lock:
self._threads.discard(thread)
raise
return {
"state": "queued",
"topic": self.topic,
"scene_id": scene_id,
"burst_count": self.burst_count,
"burst_window_ms": round(self.burst_window_s * 1_000),
}
def _run_burst(self, scene: dict[str, Any]) -> None:
started = time.monotonic()
attempts = 0
published = 0
last_delivery_status: str | None = None
last_message_id: Any = None
last_error: str | None = None
with self._lock:
self._status.update(
state="bursting",
last_burst_started_at=_utc_now(),
)
try:
for index in range(self.burst_count):
due_at = started + index * self.burst_interval_s
if self._stop.wait(max(0.0, due_at - time.monotonic())):
break
attempts += 1
try:
result = self._publisher(deepcopy(scene))
if not isinstance(result, Mapping):
raise TypeError("mode scene publisher returned an invalid result")
except Exception as exc: # noqa: BLE001 - finish the bounded burst
last_error = str(exc)[:240]
last_delivery_status = "failed"
else:
published += 1
last_delivery_status = str(
result.get("delivery_status") or "queued"
)
last_message_id = result.get("message_id")
with self._lock:
self._status.update(
state="bursting",
burst_attempt_count=attempts,
burst_published_count=published,
last_delivery_status=last_delivery_status,
last_message_id=last_message_id,
last_error=last_error,
)
finally:
completed = attempts == self.burst_count
if completed and published != self.burst_count:
last_delivery_status = "partial" if published else "failed"
with self._lock:
self._status.update(
state=(
"stopped"
if self._stop.is_set() and not completed
else "burst_complete"
if published == self.burst_count
else "burst_failed"
),
burst_attempt_count=attempts,
burst_published_count=published,
last_delivery_status=last_delivery_status,
last_message_id=last_message_id,
last_error=last_error,
last_burst_completed_at=_utc_now() if completed else None,
)
self._threads.discard(threading.current_thread())
def snapshot(self) -> dict[str, Any]:
with self._lock:
return deepcopy(self._status)
def start(self) -> None:
with self._lock:
self._stop.clear()
if self._status["state"] == "stopped":
self._status["state"] = "idle"
def close(self) -> None:
self._stop.set()
with self._lock:
threads = tuple(self._threads)
deadline = time.monotonic() + 1.1
for thread in threads:
thread.join(timeout=max(0.0, deadline - time.monotonic()))
with self._lock:
self._status["state"] = "stopped"
@@ -55,6 +55,7 @@ from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
DEFAULT_N7_SCENE_SERVER_ID,
DEFAULT_N7_SCENE_SERVER_NAME,
ModeSceneCatalogPublisher,
ModeSceneMqttBurstPublisher,
build_mode_scene_catalog,
)
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
@@ -129,6 +130,7 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
VehicleMqttSettings,
)
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
MQTT_MODE_SCENE_TOPIC,
MqttCanTransport,
)
from cognitive_agent.infrastructure.llm.openai_responses import ( # noqa: E402
@@ -440,6 +442,10 @@ class SeatDemoService:
"scene_count": 0,
"last_error": None,
}
self._mode_scene_mqtt_publisher = ModeSceneMqttBurstPublisher(
lambda scene: self._vehicle_mqtt_transport.publish_mode_scene(scene),
topic=MQTT_MODE_SCENE_TOPIC,
)
self.scene_templates = SceneTemplateRegistry(
repository_root / "config" / "scene_templates"
)
@@ -540,6 +546,51 @@ class SeatDemoService:
def mode_scene_catalog_status(self) -> dict[str, Any]:
return deepcopy(self._mode_scene_catalog_status)
def publish_saved_mode_scene(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> dict[str, Any]:
"""Queue the just-saved card from the canonical scene projection.
Both a new user mode and a user override of a repository preset are
projected by the same registry. Reading the card back from that
projection guarantees that MQTT and ``/n7/scenes`` expose exactly the
same post-save JSON object.
"""
clean_mode_id = str(mode_id).strip()
if not clean_mode_id:
raise ValueError("mode scene id must not be empty")
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
catalog = self.mode_scene_catalog(resolved_vehicle_id)
catalog_publisher = self._mode_scene_catalog_publisher
if catalog_publisher is not None and (
catalog_publisher.vehicle_id != resolved_vehicle_id
or self._mode_scene_catalog_status.get("state") != "ready"
):
raise RuntimeError("mode scene catalog is not synchronized for this vehicle")
scene = next(
(
item
for item in catalog.get("scenes", [])
if isinstance(item, dict) and item.get("id") == clean_mode_id
),
None,
)
if scene is None:
raise KeyError(clean_mode_id)
return self._mode_scene_mqtt_publisher.queue(scene)
def publish_created_mode_scene(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> dict[str, Any]:
"""Backward-compatible alias for callers predating update publishing."""
return self.publish_saved_mode_scene(mode_id, vehicle_id)
@property
def driver_only_ch1_execution_active(self) -> bool:
"""Return whether deployment policy authorizes only the CH1 vehicle path."""
@@ -697,6 +748,7 @@ class SeatDemoService:
"mode_profile_storage": "persistent_yaml",
"mode_profile_user_root": str(self.mode_profile_user_root),
"mode_scene_catalog": self.mode_scene_catalog_status(),
"mode_scene_mqtt": self._mode_scene_mqtt_publisher.snapshot(),
"preset_storage": "conversation_durable",
"inferred_level_range": [1, 3],
"motion_position_range_pct": [0, 100],
@@ -1326,11 +1378,13 @@ class SeatDemoService:
def start(self) -> None:
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.start()
self._mode_scene_mqtt_publisher.start()
self._vehicle_mqtt_transport.start()
def close(self) -> None:
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.close()
self._mode_scene_mqtt_publisher.close()
self._vehicle_mqtt_transport.close()
def publish_pet_message(self, payload: dict[str, str]) -> dict[str, Any]:
@@ -1338,6 +1392,30 @@ class SeatDemoService:
return self._vehicle_mqtt_transport.publish_pet_message(payload)
def publish_extension_toy_message(
self,
payload: dict[str, Any],
) -> dict[str, Any]:
"""Publish one NFC-detected extension capability announcement."""
return self._vehicle_mqtt_transport.publish_extension_toy_message(payload)
def set_extension_control_handler(
self,
handler: Callable[[bytes], None] | None,
) -> None:
"""Route ``extension/ctl`` payloads from the shared broker connection."""
self._vehicle_mqtt_transport.set_extension_control_handler(handler)
def set_extension_control_rejection_handler(
self,
handler: Callable[[str, str], None] | None,
) -> None:
"""Project broker-level ``extension/ctl`` rejections into the UI status."""
self._vehicle_mqtt_transport.set_extension_control_rejection_handler(handler)
def _synchronize_vehicle_state_once(
self,
conversation_id: str,
+123 -37
View File
@@ -12,6 +12,7 @@ import ssl
import threading
import time
import uuid
from collections.abc import Mapping
from contextlib import closing
from datetime import UTC, datetime
from pathlib import Path
@@ -955,7 +956,7 @@ def _peripheral_execution_identity_authorized(
capability: dict[str, Any],
) -> bool:
# Saved modes execute an immutable, previously validated command flow and
# are rebound to the same live NFC/device/slot tuple before reaching here.
# are rebound to the same persisted NFC/device/slot tuple before reaching here.
if capability.get("parse_source") == "saved_mode_command_flow":
return True
return bool(_device_match_score(_compact_match_text(utterance), capability))
@@ -1089,7 +1090,7 @@ def _deterministic_peripheral_steps(
utterance: str,
catalog: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Build an ordered live-peripheral plan when every phrase is unambiguous."""
"""Build an ordered bound-peripheral plan when every phrase is unambiguous."""
compact_utterance = _compact_match_text(utterance)
learned = [
@@ -1229,11 +1230,13 @@ def resolve_active_peripheral_command(
catalog: list[dict[str, Any]],
nfc_snapshot: dict[str, Any],
) -> dict[str, Any] | None:
"""Resolve an exact learned action for the NFC card that is present now.
"""Resolve an exact learned action from the durable NFC-bound catalog.
An exact peripheral name is required so generic vehicle phrases such as
``打开氛围灯`` keep using the native vehicle domain. Action synonyms are
accepted only after the peripheral identity and live card binding match.
accepted only after the peripheral identity and persisted binding match.
``nfc_snapshot`` is retained in the public signature for compatibility;
card presence is intentionally not an execution prerequisite.
"""
return _resolve_bound_peripheral_command(
@@ -1251,20 +1254,17 @@ def _resolve_bound_peripheral_command(
*,
require_enabled: bool,
) -> dict[str, Any] | None:
"""Resolve a card-bound command, optionally including disabled entries.
"""Resolve a persistently card-bound command, including disabled entries.
Disabled entries are inspected only so the partitioner can report that a
requested action was filtered by the NFC command allowlist. Execution
requested action was filtered by the persisted command allowlist. Execution
continues to use :func:`resolve_active_peripheral_command`, which always
requires ``enabled`` to be true.
"""
del nfc_snapshot # Presence is display state, not persistent authorization.
compact_utterance = _compact_match_text(utterance)
card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None
active_uid = _normalized_card_uid(
card.get("uid") if isinstance(card, dict) else None
)
if not compact_utterance or not active_uid:
if not compact_utterance:
return None
matches: list[tuple[int, int, dict[str, Any]]] = []
@@ -1278,7 +1278,8 @@ def _resolve_bound_peripheral_command(
)
):
continue
if _normalized_card_uid(raw_item.get("card_uid")) != active_uid:
bound_uid = _normalized_card_uid(raw_item.get("card_uid"))
if not bound_uid:
continue
device_score = _device_match_score(compact_utterance, raw_item)
if not device_score:
@@ -1308,7 +1309,7 @@ def _resolve_bound_peripheral_command(
resolved = dict(matches[0][2])
resolved["slot"] = validate_slot(resolved.get("slot"))
resolved["card_uid"] = active_uid
resolved["card_uid"] = _normalized_card_uid(resolved.get("card_uid"))
return resolved
@@ -1326,17 +1327,13 @@ def partition_active_peripheral_intent(
"""Claim only explicit learned-peripheral clauses and preserve CAN text.
The function is deliberately conservative: a clause must contain both a
live NFC-bound device identity and one learned action. When no peripheral
persistently NFC-bound device identity and one learned action. When no peripheral
clause is claimed, ``residual_utterance`` is byte-for-byte the original
trimmed input so the existing CAN parser observes exactly its old input.
"""
original = str(utterance or "").strip()
card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None
active_uid = _normalized_card_uid(
card.get("uid") if isinstance(card, dict) else None
)
if not original or not active_uid:
if not original:
return {
"peripheral_actions": [],
"residual_utterance": original,
@@ -1350,7 +1347,7 @@ def partition_active_peripheral_intent(
for item in catalog[:MAX_CATALOG_ITEMS]
if isinstance(item, dict)
and item.get("status") == "learned"
and _normalized_card_uid(item.get("card_uid")) == active_uid
and bool(_normalized_card_uid(item.get("card_uid")))
]
active_catalog = [
item
@@ -1541,14 +1538,11 @@ def active_peripheral_plan_catalog(
catalog: list[dict[str, Any]],
nfc_snapshot: dict[str, Any],
) -> list[dict[str, Any]]:
"""Return the live-card catalog when a phrase needs an ordered plan."""
"""Return the durable bound catalog when a phrase needs an ordered plan."""
del nfc_snapshot # Presence is display state, not persistent authorization.
compact_utterance = _compact_match_text(utterance)
card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None
active_uid = _normalized_card_uid(
card.get("uid") if isinstance(card, dict) else None
)
if not compact_utterance or not active_uid:
if not compact_utterance:
return []
has_timing = bool(
re.search(r"\d+(?:\.\d+)?(?:ms|s|毫秒|秒|分钟|分)", compact_utterance)
@@ -1568,13 +1562,14 @@ def active_peripheral_plan_catalog(
or raw_item.get("enabled", True) is False
):
continue
if _normalized_card_uid(raw_item.get("card_uid")) != active_uid:
bound_uid = _normalized_card_uid(raw_item.get("card_uid"))
if not bound_uid:
continue
if not _device_match_score(compact_utterance, raw_item):
continue
item = dict(raw_item)
item["slot"] = validate_slot(item.get("slot"))
item["card_uid"] = active_uid
item["card_uid"] = bound_uid
active.append(item)
if _has_unshadowed_vehicle_device_phrase(compact_utterance, active):
return []
@@ -2378,6 +2373,61 @@ class SelfExtensionService:
result["learning_capture_confirmed"] = False
return result
def execute_bound_infrared_action(
self,
action: Mapping[str, Any],
) -> dict[str, Any]:
"""Re-authorize and emit one car-originated ``extension/ctl`` action."""
if (
not isinstance(action, Mapping)
or action.get("type") != "infrared_emit"
or action.get("domain") != "self_extension"
or action.get("capability") != "infrared.emit"
or action.get("operation") != "emit"
):
raise ExtensionIntegrationError("车端外设控制动作格式无效。")
device_id = str(action.get("target_scope") or "").strip()
parameters = action.get("parameters")
if not device_id or not isinstance(parameters, Mapping):
raise ExtensionIntegrationError("车端外设控制动作缺少设备或参数。")
slot = validate_slot(parameters.get("slot"))
action_name = str(parameters.get("action") or "").strip()
if not action_name:
raise ExtensionIntegrationError("车端外设控制动作名称不能为空。")
capability = next(
(
item
for item in self.list_peripheral_capabilities()
if isinstance(item, dict)
and item.get("status") == "learned"
and item.get("enabled") is True
and bool(_normalized_card_uid(item.get("card_uid")))
and str(item.get("device_id") or "").strip() == device_id
and item.get("slot") == slot
and str(item.get("action") or "").strip() == action_name
),
None,
)
if capability is None:
raise ExtensionIntegrationError(
"车端外设控制动作未通过本机持久能力鉴权:"
f"{device_id} / {action_name} / IR {slot}。"
)
result = self.execute_gateway("emit", {"slot": slot})
return {
**result,
"authorization": {
"source": "local_persistent_extension_allowlist",
"capability_id": capability.get("id"),
"device_id": device_id,
"action": action_name,
"slot": slot,
},
}
def execute_peripheral_request(
self,
utterance: str,
@@ -2389,7 +2439,7 @@ class SelfExtensionService:
provider_requested: str = "auto",
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Execute a live-NFC peripheral match through the main request API."""
"""Execute a persistently NFC-bound match through the main request API."""
if not _peripheral_execution_identity_authorized(utterance, capability):
raise ExtensionIntegrationError(
@@ -2536,14 +2586,14 @@ class SelfExtensionService:
"execution_mode": execution_mode,
"control_transport": control_transport,
"decision_summary": [
f"检测到在场 NFC {capability.get('card_uid')},已优先匹配绑定外设。",
f"已通过持久 NFC 绑定 {capability.get('card_uid')} 匹配外设能力。",
f"命中已学习动作:{capability.get('device_name')} · {capability.get('action')}(槽位 {slot})。",
],
"trace_events": [
{
"stage": "nfc_peripheral_route",
"status": "succeeded",
"summary": "在场 NFC、外设名称与已学习动作均匹配",
"summary": "持久 NFC 绑定、外设名称与已学习动作均匹配",
"duration_ms": 0,
}
],
@@ -2592,13 +2642,13 @@ class SelfExtensionService:
provider_requested: str = "auto",
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Execute a validated ordered peripheral/delay plan for the live NFC."""
"""Execute a validated ordered plan from durable NFC-bound abilities."""
steps = plan_result.get("steps", [])
if not isinstance(steps, list) or not steps:
raise ExtensionIntegrationError("GPT 没有生成可执行的外设动作。")
if any(step.get("kind") == "vehicle" for step in steps if isinstance(step, dict)):
raise ExtensionIntegrationError("当前主编排只允许执行在场 NFC 外设计划。")
raise ExtensionIntegrationError("当前主编排只允许执行持久绑定的 NFC 外设计划。")
catalog_by_id = {
str(item.get("id")): item
for item in catalog
@@ -2639,9 +2689,14 @@ class SelfExtensionService:
raise ExtensionIntegrationError("GPT 计划包含未知步骤类型。")
capability_id = str(step.get("capability_id") or "")
capability = catalog_by_id.get(capability_id)
if capability is None or capability.get("status") != "learned":
if (
capability is None
or capability.get("status") != "learned"
or capability.get("enabled", True) is False
or not _normalized_card_uid(capability.get("card_uid"))
):
raise ExtensionIntegrationError(
"GPT 计划引用了不在当前 NFC 目录中的能力。"
"GPT 计划引用了未学习、未启用或缺少持久 NFC 绑定的能力。"
)
if not _peripheral_execution_identity_authorized(utterance, capability):
raise ExtensionIntegrationError(
@@ -2880,6 +2935,37 @@ class SelfExtensionService:
}
operations.append(operation)
if interrupted:
for remaining_index in range(index + 1, len(steps)):
remaining_step = steps[remaining_index]
if remaining_step.get("kind") != "peripheral":
continue
(
_remaining_capability_id,
_remaining_capability,
remaining_slot,
remaining_operation_ref,
) = compiled_peripheral_steps[remaining_index]
operations.append(
{
"ref": remaining_operation_ref,
"domain": "self_extension",
"status": "aborted",
"message": "前序红外指令失败,未执行",
"telemetry": {
"transport": transport,
"slot": remaining_slot,
"delivery_status": "not_submitted",
"transport_submitted": False,
"gateway_acknowledged": False,
"physical_effect_confirmed": False,
"command": build_gateway_command(
"emit", {"slot": remaining_slot}
),
"effect_mode": "one_shot",
"abort_reason": "prior_operation_failed",
},
}
)
break
if (
hardware_request
@@ -2943,10 +3029,10 @@ class SelfExtensionService:
else "GPT 已生成外设顺序计划。"
)
),
f"计划仅引用当前在场 NFC {first_capability.get('card_uid')} 的已学习能力。",
f"计划仅引用持久 NFC 绑定 {first_capability.get('card_uid')} 的已学习能力。",
*(
[
"当前 NFC 未学习专用闪烁能力;为避免灯具停在关闭态及学习器持续蜂鸣,"
"当前绑定未学习专用闪烁能力;为避免灯具停在关闭态及学习器持续蜂鸣,"
"闪烁已安全降级为定色保持。"
]
if steady_blink_fallbacks
@@ -37,6 +37,12 @@ from cognitive_agent.infrastructure.can.hardware import VehicleMqttSettings
MQTT_CAN_SCHEMA = "n7.can.frame.v1"
MQTT_PET_TOPIC = "n7/pet"
MQTT_EXTENSION_TOY_TOPIC = "extension/toy"
MQTT_EXTENSION_TOY_QOS = 1
MQTT_EXTENSION_CONTROL_TOPIC = "extension/ctl"
MQTT_EXTENSION_CONTROL_QOS = 1
MQTT_EXTENSION_CONTROL_MAX_PAYLOAD_BYTES = 256 * 1024
MQTT_MODE_SCENE_TOPIC = "from_pc/scenes"
MQTT_POLL_INTERVAL_MS = 500
WIRELESS_MQTT_CH1_SOURCE = "wireless_mqtt_ch1"
MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
@@ -500,7 +506,7 @@ def mqtt_control_payload(
def mqtt_pet_payload(payload: Mapping[str, Any]) -> str:
"""Serialize the only application-level MQTT message emitted by N7Web."""
"""Serialize one NFC-authorized pet presence message."""
if set(payload) != {"pet_name", "nfc_id", "action"}:
raise CanGatewayError(
@@ -524,6 +530,208 @@ def mqtt_pet_payload(payload: Mapping[str, Any]) -> str:
)
_EXTENSION_TOY_ACTION_KEYS = frozenset(
{
"type",
"at_ms",
"domain",
"capability",
"target_scope",
"operation",
"parameters",
}
)
def mqtt_extension_toy_payload(payload: Mapping[str, Any]) -> str:
"""Serialize one NFC-detected extension capability announcement."""
if not isinstance(payload, Mapping) or set(payload) != {
"extension_name",
"actions",
}:
raise CanGatewayError(
"extension/toy MQTT payload requires exactly extension_name and actions"
)
extension_name = payload.get("extension_name")
if (
not isinstance(extension_name, str)
or not extension_name.strip()
or len(extension_name.strip()) > 64
):
raise CanGatewayError(
"extension_name must contain between 1 and 64 characters"
)
raw_actions = payload.get("actions")
if not isinstance(raw_actions, list) or not 1 <= len(raw_actions) <= 248:
raise CanGatewayError("extension actions must contain between 1 and 248 items")
actions: list[dict[str, Any]] = []
for raw_action in raw_actions:
if not isinstance(raw_action, Mapping) or set(raw_action) != _EXTENSION_TOY_ACTION_KEYS:
raise CanGatewayError(
"extension action requires exactly type, at_ms, domain, capability, "
"target_scope, operation and parameters"
)
if raw_action.get("type") != "infrared_emit":
raise CanGatewayError("extension action type must be infrared_emit")
at_ms = raw_action.get("at_ms")
if isinstance(at_ms, bool) or not isinstance(at_ms, int) or at_ms < 0:
raise CanGatewayError("extension action at_ms must be a non-negative integer")
if raw_action.get("domain") != "self_extension":
raise CanGatewayError("extension action domain must be self_extension")
if raw_action.get("capability") != "infrared.emit":
raise CanGatewayError("extension action capability must be infrared.emit")
if raw_action.get("operation") != "emit":
raise CanGatewayError("extension action operation must be emit")
target_scope = raw_action.get("target_scope")
if (
not isinstance(target_scope, str)
or not target_scope
or len(target_scope) > 128
or not target_scope[0].isalnum()
or any(
not (character.isascii() and (character.isalnum() or character in "._:-"))
for character in target_scope
)
):
raise CanGatewayError(
"extension action target_scope must be an English stable device id"
)
parameters = raw_action.get("parameters")
if not isinstance(parameters, Mapping) or set(parameters) != {"action", "slot"}:
raise CanGatewayError(
"extension action parameters require exactly action and slot"
)
action_name = parameters.get("action")
if (
not isinstance(action_name, str)
or not action_name.strip()
or len(action_name.strip()) > 48
):
raise CanGatewayError(
"extension action parameter action must contain between 1 and 48 characters"
)
slot = parameters.get("slot")
if isinstance(slot, bool) or not isinstance(slot, int) or not 1 <= slot <= 248:
raise CanGatewayError("extension action parameter slot must be in range 1-248")
actions.append(
{
"type": "infrared_emit",
"at_ms": at_ms,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": target_scope,
"operation": "emit",
"parameters": {"action": action_name.strip(), "slot": slot},
}
)
return json.dumps(
{"extension_name": extension_name.strip(), "actions": actions},
ensure_ascii=False,
separators=(",", ":"),
allow_nan=False,
)
_MODE_SCENE_REQUIRED_KEYS = frozenset(
{
"id",
"label",
"name",
"background_time",
"description",
"icon",
"aliases",
"origin",
"deletable",
"behavior",
"domains",
"operation_count",
"scope",
"profile_revision",
"actions",
}
)
_MODE_SCENE_ACTION_REQUIRED_KEYS = frozenset(
{
"type",
"at_ms",
"domain",
"capability",
"target_scope",
"operation",
"parameters",
}
)
def mqtt_mode_scene_payload(payload: Mapping[str, Any]) -> str:
"""Serialize exactly one ``/n7/scenes`` card, never the catalog wrapper."""
if not isinstance(payload, Mapping):
raise CanGatewayError("mode scene MQTT payload must be a JSON object")
keys = set(payload)
if {"service", "scenes", "scene_count"} & keys:
raise CanGatewayError("mode scene MQTT payload must contain one scene only")
missing = sorted(_MODE_SCENE_REQUIRED_KEYS - keys)
if missing:
raise CanGatewayError(
f"mode scene MQTT payload is missing required fields: {', '.join(missing)}"
)
scene_id = payload.get("id")
if not isinstance(scene_id, str) or not scene_id.strip() or len(scene_id) > 64:
raise CanGatewayError("mode scene id must contain between 1 and 64 characters")
if not isinstance(payload.get("name"), str) or not str(payload["name"]).strip():
raise CanGatewayError("mode scene name must not be empty")
if not isinstance(payload.get("aliases"), list):
raise CanGatewayError("mode scene aliases must be an array")
if not isinstance(payload.get("domains"), list):
raise CanGatewayError("mode scene domains must be an array")
if not isinstance(payload.get("scope"), Mapping):
raise CanGatewayError("mode scene scope must be an object")
operation_count = payload.get("operation_count")
actions = payload.get("actions")
if (
isinstance(operation_count, bool)
or not isinstance(operation_count, int)
or operation_count < 1
):
raise CanGatewayError("mode scene operation_count must be a positive integer")
if not isinstance(actions, list) or len(actions) != operation_count:
raise CanGatewayError("mode scene actions must match operation_count")
for action in actions:
if not isinstance(action, Mapping):
raise CanGatewayError("mode scene actions must contain JSON objects")
action_missing = sorted(_MODE_SCENE_ACTION_REQUIRED_KEYS - set(action))
if action_missing:
raise CanGatewayError(
"mode scene action is missing required fields: "
+ ", ".join(action_missing)
)
if not isinstance(action.get("parameters"), Mapping):
raise CanGatewayError("mode scene action parameters must be an object")
try:
raw_payload = json.dumps(
deepcopy(dict(payload)),
ensure_ascii=False,
separators=(",", ":"),
allow_nan=False,
)
except (TypeError, ValueError) as exc:
raise CanGatewayError("mode scene MQTT payload is not valid JSON") from exc
if len(raw_payload.encode("utf-8")) > 512 * 1024:
raise CanGatewayError("mode scene MQTT payload exceeds 512 KiB")
return raw_payload
def _validate_native_seat_motion_control(data: bytes) -> None:
"""Validate the firmware-native 0x300 semantic runtime subset.
@@ -644,6 +852,17 @@ class MqttCanTransport:
self._latest_bus_frames: dict[
tuple[int, int, int], tuple[float, CanFrame]
] = {}
self._extension_control_handler: Callable[[bytes], None] | None = None
self._extension_control_rejection_handler: (
Callable[[str, str], None] | None
) = None
self._extension_control_received_count = 0
self._extension_control_dispatched_count = 0
self._extension_control_rejected_count = 0
self._extension_control_duplicate_count = 0
self._extension_control_retained_count = 0
self._extension_control_last_received_at: str | None = None
self._extension_control_last_error: str | None = None
def _build_client(self) -> Any:
if self.client_factory is not None:
@@ -725,6 +944,40 @@ class MqttCanTransport:
self._state = "stopped"
self._condition.notify_all()
def set_extension_control_handler(
self,
handler: Callable[[bytes], None] | None,
) -> None:
"""Register the non-blocking application queue for ``extension/ctl``."""
if handler is not None and not callable(handler):
raise TypeError("extension control handler must be callable")
with self._condition:
self._extension_control_handler = handler
self._condition.notify_all()
def set_extension_control_rejection_handler(
self,
handler: Callable[[str, str], None] | None,
) -> None:
"""Register a status observer for MQTT-layer control rejections."""
if handler is not None and not callable(handler):
raise TypeError("extension control rejection handler must be callable")
with self._condition:
self._extension_control_rejection_handler = handler
self._condition.notify_all()
def _notify_extension_control_rejection(self, error: str, kind: str) -> None:
with self._condition:
handler = self._extension_control_rejection_handler
if handler is None:
return
try:
handler(error[:240], kind)
except Exception: # noqa: BLE001, S110 - display observer is best effort
pass
@staticmethod
def _reason_code_value(reason_code: Any) -> int:
try:
@@ -747,15 +1000,37 @@ class MqttCanTransport:
self._last_error = f"MQTT connection refused with reason {code}"
self._condition.notify_all()
return
result = client.subscribe(self.settings.condition_topic, qos=self.settings.qos)
result_code = result[0] if isinstance(result, tuple) else getattr(result, "rc", 0)
subscriptions = (
(self.settings.condition_topic, self.settings.qos),
(MQTT_EXTENSION_CONTROL_TOPIC, MQTT_EXTENSION_CONTROL_QOS),
)
failed_subscription: tuple[str, int] | None = None
try:
for topic, qos in subscriptions:
result = client.subscribe(topic, qos=qos)
result_code = (
result[0] if isinstance(result, tuple) else getattr(result, "rc", 0)
)
if int(result_code) != 0:
failed_subscription = (topic, int(result_code))
break
except Exception as exc: # noqa: BLE001 - normalize client callback failures
failed_subscription = ("subscription", -1)
subscription_error = str(exc)
else:
subscription_error = ""
with self._condition:
if int(result_code) == 0:
if failed_subscription is None:
self._state = "online"
self._last_error = None
else:
self._state = "error"
self._last_error = f"MQTT subscribe failed with code {result_code}"
failed_topic, failed_code = failed_subscription
self._last_error = (
f"MQTT subscribe failed for {failed_topic}: {subscription_error}"
if subscription_error
else f"MQTT subscribe failed for {failed_topic} with code {failed_code}"
)
self._condition.notify_all()
def _on_disconnect(
@@ -775,7 +1050,11 @@ class MqttCanTransport:
self._condition.notify_all()
def _on_message(self, _client: Any, _userdata: Any, message: Any) -> None:
if str(getattr(message, "topic", "")) != self.settings.condition_topic:
topic = str(getattr(message, "topic", ""))
if topic == MQTT_EXTENSION_CONTROL_TOPIC:
self._dispatch_extension_control_message(message)
return
if topic != self.settings.condition_topic:
return
try:
frames = parse_mqtt_can_payload(bytes(message.payload))
@@ -787,6 +1066,74 @@ class MqttCanTransport:
self._last_error = str(exc)
self._condition.notify_all()
def _dispatch_extension_control_message(self, message: Any) -> None:
"""Forward a fresh control payload to the application queue without blocking."""
received_at = _now()
transport_rejection: tuple[str, str] | None = None
with self._condition:
self._extension_control_received_count += 1
self._extension_control_last_received_at = received_at
if bool(getattr(message, "retain", False)):
self._extension_control_retained_count += 1
self._extension_control_rejected_count += 1
self._extension_control_last_error = (
"retained extension/ctl messages are not executable"
)
transport_rejection = (
self._extension_control_last_error,
"retained",
)
self._condition.notify_all()
elif bool(getattr(message, "dup", False)):
self._extension_control_duplicate_count += 1
self._extension_control_rejected_count += 1
self._extension_control_last_error = (
"duplicate extension/ctl delivery was ignored"
)
transport_rejection = (
self._extension_control_last_error,
"duplicate",
)
self._condition.notify_all()
else:
handler = self._extension_control_handler
if transport_rejection is not None:
self._notify_extension_control_rejection(*transport_rejection)
return
try:
payload = bytes(message.payload)
except (AttributeError, TypeError, ValueError) as exc:
error = f"extension/ctl payload is not binary data: {exc}"
rejection_kind = "payload"
else:
if len(payload) > MQTT_EXTENSION_CONTROL_MAX_PAYLOAD_BYTES:
error = "extension/ctl payload exceeds 256 KiB"
rejection_kind = "payload"
elif handler is None:
error = "extension/ctl handler is not configured"
rejection_kind = "unavailable"
else:
try:
handler(payload)
except Exception as exc: # noqa: BLE001 - isolate MQTT network loop
error = str(exc)[:240]
rejection_kind = "dispatch"
else:
with self._condition:
self._extension_control_dispatched_count += 1
self._extension_control_last_error = None
self._condition.notify_all()
return
with self._condition:
self._extension_control_rejected_count += 1
self._extension_control_last_error = error[:240]
self._condition.notify_all()
self._notify_extension_control_rejection(error, rejection_kind)
def _record_frame(self, frame: CanFrame) -> None:
if (
frame.channel not in MQTT_ACCEPTED_FEEDBACK_IDS
@@ -1023,6 +1370,67 @@ class MqttCanTransport:
"message_id": getattr(result, "mid", None),
}
def publish_extension_toy_message(
self,
payload: Mapping[str, Any],
) -> dict[str, Any]:
"""Queue one validated extension capability announcement."""
raw_payload = mqtt_extension_toy_payload(payload)
with self._condition:
client = self._client
state = self._state
if client is None or state != "online":
raise CanGatewayError("extension/toy MQTT publisher is not connected")
result = client.publish(
MQTT_EXTENSION_TOY_TOPIC,
raw_payload,
qos=MQTT_EXTENSION_TOY_QOS,
retain=False,
)
if int(getattr(result, "rc", 0)) != 0:
raise CanGatewayError(
f"extension/toy MQTT publish failed with code {result.rc}"
)
is_published = getattr(result, "is_published", None)
broker_confirmed = bool(is_published()) if callable(is_published) else False
return {
"topic": MQTT_EXTENSION_TOY_TOPIC,
"qos": MQTT_EXTENSION_TOY_QOS,
"retain": False,
"delivery_status": "published" if broker_confirmed else "queued",
"message_id": getattr(result, "mid", None),
}
def publish_mode_scene(self, payload: Mapping[str, Any]) -> dict[str, Any]:
"""Queue one exact N7 scene-card projection on its fixed sync topic."""
raw_payload = mqtt_mode_scene_payload(payload)
with self._condition:
client = self._client
state = self._state
if client is None or state != "online":
raise CanGatewayError("mode scene MQTT publisher is not connected")
result = client.publish(
MQTT_MODE_SCENE_TOPIC,
raw_payload,
qos=self.settings.qos,
retain=False,
)
if int(getattr(result, "rc", 0)) != 0:
raise CanGatewayError(
f"mode scene MQTT publish failed with code {result.rc}"
)
is_published = getattr(result, "is_published", None)
broker_confirmed = bool(is_published()) if callable(is_published) else False
return {
"topic": MQTT_MODE_SCENE_TOPIC,
"qos": self.settings.qos,
"retain": False,
"delivery_status": "published" if broker_confirmed else "queued",
"message_id": getattr(result, "mid", None),
}
def _receive_after(
self,
cursor: int,
@@ -1076,6 +1484,22 @@ class MqttCanTransport:
"stale_frames": self._stale_frames,
"last_error": self._last_error,
}
info["extension_control"] = {
"mode": "persistent_mqtt_subscription",
"topic": MQTT_EXTENSION_CONTROL_TOPIC,
"qos": MQTT_EXTENSION_CONTROL_QOS,
"handler_configured": self._extension_control_handler is not None,
"rejection_handler_configured": (
self._extension_control_rejection_handler is not None
),
"received_count": self._extension_control_received_count,
"dispatched_count": self._extension_control_dispatched_count,
"rejected_count": self._extension_control_rejected_count,
"duplicate_count": self._extension_control_duplicate_count,
"retained_count": self._extension_control_retained_count,
"last_received_at": self._extension_control_last_received_at,
"last_error": self._extension_control_last_error,
}
return info
+123 -23
View File
@@ -43,11 +43,23 @@ from cognitive_agent.application.codex_assistant import ( # noqa: E402
CodexAssistantService,
CodexVehicleToolError,
)
from cognitive_agent.application.extension_control import ( # noqa: E402
ExtensionControlConsumer,
)
from cognitive_agent.application.extension_toy_publisher import ( # noqa: E402
EXTENSION_TOY_BURST_COUNT,
EXTENSION_TOY_BURST_WINDOW_S,
NfcExtensionToyPublisher,
)
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
normalize_mode_profile_name,
parse_mode_profile_update,
)
from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
MODE_SCENE_MQTT_BURST_COUNT,
MODE_SCENE_MQTT_BURST_WINDOW_S,
)
from cognitive_agent.application.pet_cards import PetCardService # noqa: E402
from cognitive_agent.application.seat_service import ( # noqa: E402
LOCAL_CONTROL_BUDGET_MS,
@@ -78,6 +90,11 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
HardwareBusyError,
HardwareSafetyError,
)
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
MQTT_EXTENSION_CONTROL_TOPIC,
MQTT_EXTENSION_TOY_TOPIC,
MQTT_MODE_SCENE_TOPIC,
)
from cognitive_agent.infrastructure.codex.app_server import ( # noqa: E402
CodexAppServerClient,
CodexAppServerError,
@@ -89,7 +106,13 @@ from cognitive_agent.infrastructure.speech import ( # noqa: E402
SpeechTranscriber,
)
STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
WEB_CONSOLE_SOURCE_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
WEB_CONSOLE_DIST_ROOT = WEB_CONSOLE_SOURCE_ROOT / "dist"
STATIC_ROOT = (
WEB_CONSOLE_DIST_ROOT
if (WEB_CONSOLE_DIST_ROOT / "index.html").is_file()
else WEB_CONSOLE_SOURCE_ROOT
)
DEFAULT_MODE_SCENE_CATALOG_PATH = (
REPOSITORY_ROOT.parent / "scenses" / "demo-scenes.json"
)
@@ -222,6 +245,19 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
nfc_provider=nfc.snapshot,
publisher=service.publish_pet_message,
)
extension_toys = NfcExtensionToyPublisher(
nfc_provider=nfc.snapshot,
catalog_provider=extension.list_peripheral_capabilities,
publisher=service.publish_extension_toy_message,
)
extension_control = ExtensionControlConsumer(
catalog_provider=extension.list_peripheral_capabilities,
executor=extension.execute_bound_infrared_action,
)
service.set_extension_control_handler(extension_control.submit)
service.set_extension_control_rejection_handler(
extension_control.reject_transport_message
)
calibration = CalibrationService(
REPOSITORY_ROOT / "artifacts" / "tmp" / "self-calibration",
repository_root=REPOSITORY_ROOT,
@@ -328,6 +364,27 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path == "/api/vehicle-display-sync":
self._json(self.vehicle_display_sync.snapshot(self._extension_health()))
return
if path == "/api/extension/control/status":
query = parse_qs(parsed_url.query)
try:
after_revision = int(query.get("after_revision", ["-1"])[0])
timeout_ms = int(query.get("timeout_ms", ["25000"])[0])
if after_revision < -1:
raise ValueError("after_revision must be at least -1")
if not 0 <= timeout_ms <= 30_000:
raise ValueError("timeout_ms must be between 0 and 30000")
snapshot = self.extension_control.wait_for_snapshot(
after_revision,
timeout_s=timeout_ms / 1_000,
)
except (TypeError, ValueError) as exc:
self._json(
{"error": "invalid_request", "message": str(exc)},
HTTPStatus.BAD_REQUEST,
)
return
self._json(snapshot)
return
if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
query = parse_qs(parsed_url.query)
conversation_id = str(query.get("conversation_id", [""])[0])
@@ -403,6 +460,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"success": True,
**self.nfc.snapshot(),
"pet_cards": self.pets.snapshot(),
"extension_toy": self.extension_toys.snapshot(),
}
)
return
@@ -885,7 +943,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
on_commands_validated: Callable[[dict[str, object]], None] | None = None,
natural_language_routing: Mapping[str, object] | None = None,
) -> dict[str, object] | None:
"""Route a command to the NFC-bound peripheral when one is present."""
"""Route a command to a persistently NFC-bound peripheral ability."""
# The browser may disappear or reload between learning and execution.
# Natural-language routing therefore reads the durable, validated
@@ -986,7 +1044,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
Vehicle speech keeps using its cached structured semantic payload. A
learned peripheral cannot be represented by the static vehicle
capability catalog, so it may instead reuse the exact server-owned ASR
transcript only after the live NFC binding and learned action resolve.
transcript only after the durable NFC binding and learned action resolve.
"""
text = str(utterance or "").strip()
@@ -1328,7 +1386,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
result: dict[str, object],
entries: list[dict[str, object]],
) -> dict[str, object]:
"""Expose command-level NFC denials without discarding allowed work."""
"""Expose disabled bound commands without discarding allowed work."""
if not entries:
return result
@@ -1358,7 +1416,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
for item in denied_actions
)
summaries.append(
f"当前 NFC 未授权 {len(denied_actions)} 条红外命令,已过滤:{labels}。"
f"持久绑定中有 {len(denied_actions)} 条红外能力未启用,已过滤:{labels}。"
)
result["decision_summary"] = summaries
@@ -1487,7 +1545,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
re.search(
r"然后|随后|接着|之后|"
r"(?:\d+(?:\.\d+)?|[一二三四五六七八九十两半]+)"
r"(?:毫秒|秒|分钟)",
r"(?:ms|s|毫秒|秒|分钟)",
residual,
)
)
@@ -1777,7 +1835,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if isinstance(item, dict)
)
raise ExtensionIntegrationError(
f"当前 NFC 未授权执行以下红外命令:{labels}。"
f"以下持久绑定红外能力未启用,无法执行:{labels}。"
)
result["natural_language_routing"] = deepcopy(natural_language_routing)
@@ -2653,6 +2711,24 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if create_mode
else self.service.save_mode_profile_card(mode_id, *arguments)
)
saved_profile = result.get("mode_profile") or {}
saved_mode_id = str(saved_profile.get("id") or "").strip()
try:
result["mode_scene_mqtt"] = self.service.publish_saved_mode_scene(
saved_mode_id,
str(result.get("vehicle_id") or "") or None,
)
except Exception as exc: # noqa: BLE001 - card persistence must survive MQTT loss
result["mode_scene_mqtt"] = {
"state": "queue_failed",
"topic": MQTT_MODE_SCENE_TOPIC,
"scene_id": saved_mode_id or None,
"burst_count": MODE_SCENE_MQTT_BURST_COUNT,
"burst_window_ms": round(
MODE_SCENE_MQTT_BURST_WINDOW_S * 1_000
),
"last_error": str(exc)[:240],
}
self._json(result, HTTPStatus.CREATED if create_mode else HTTPStatus.OK)
except KeyError:
self._json(
@@ -2711,18 +2787,6 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if not operations:
return []
snapshot = self.nfc.snapshot()
card = snapshot.get("card") if isinstance(snapshot, dict) else None
active_uid = re.sub(
r"[^0-9A-Fa-f]",
"",
str((card or {}).get("uid") or ""),
).upper()
if not active_uid or str((card or {}).get("action") or "present") != "present":
raise ExtensionIntegrationError(
"该模式包含 NFC 红外指令,请先放置生成此模式时绑定的 NFC 卡。"
)
catalog = self.extension.list_peripheral_capabilities()
entries: list[dict[str, object]] = []
for index, operation in enumerate(operations):
@@ -2737,6 +2801,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"已保存模式包含无效的红外槽位。"
) from exc
device_id = str(target.get("device") or "").strip()
bound_uid = re.sub(
r"[^0-9A-Fa-f]",
"",
str(target.get("card_uid") or ""),
).upper()
if not bound_uid:
raise ExtensionIntegrationError(
f"已保存模式中的红外命令缺少持久 NFC 绑定:{device_id} / IR {slot}。"
)
capability = next(
(
item
@@ -2750,13 +2823,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"",
str(item.get("card_uid") or ""),
).upper()
== active_uid
== bound_uid
),
None,
)
if capability is None:
raise ExtensionIntegrationError(
f"当前 NFC 未授权模式中的红外命令:{device_id} / IR {slot}。"
f"持久绑定目录未授权模式中的红外命令:{device_id} / IR {slot}。"
)
entries.append(
{
@@ -2798,8 +2871,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
utterance=utterance,
source=source,
)
# Validate the complete saved flow, including the live NFC
# binding, before any transport is allowed to execute.
# Validate the complete saved flow against the durable NFC binding
# before any transport is allowed to execute.
entries = self._saved_mode_infrared_entries(profile)
can_result = self.service.execute_mode_profile_card(
mode_id,
@@ -3321,6 +3394,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
**self.extension.health(),
"nfc": self.nfc.snapshot(),
"pet_cards": self.pets.snapshot(),
"extension_toy": self.extension_toys.snapshot(),
"extension_control": self.extension_control.snapshot(),
}
def _handle_hardware_access_post(self, path: str) -> None:
@@ -3963,8 +4038,10 @@ def main() -> None:
)
SeatDemoHandler.service.start()
SeatDemoHandler.extension.start()
SeatDemoHandler.extension_control.start()
SeatDemoHandler.nfc.start()
SeatDemoHandler.pets.start()
SeatDemoHandler.extension_toys.start()
SeatDemoHandler.calibration.start()
SeatDemoHandler.repair.start()
if args.host in {"0.0.0.0", "::"}:
@@ -3995,6 +4072,25 @@ def main() -> None:
file=sys.stderr,
flush=True,
)
print(
"N7 new-mode MQTT sync: "
f"{MQTT_MODE_SCENE_TOPIC} "
f"({MODE_SCENE_MQTT_BURST_COUNT} frames within "
f"{round(MODE_SCENE_MQTT_BURST_WINDOW_S * 1_000)} ms)",
flush=True,
)
print(
"N7 extension capability MQTT: "
f"{MQTT_EXTENSION_TOY_TOPIC} "
f"({EXTENSION_TOY_BURST_COUNT} frames within "
f"{round(EXTENSION_TOY_BURST_WINDOW_S * 1_000)} ms per NFC detection)",
flush=True,
)
print(
"N7 extension control MQTT: "
f"subscribe {MQTT_EXTENSION_CONTROL_TOPIC} -> local infrared gateway",
flush=True,
)
hardware_execution_ready = any(
(
SeatDemoHandler.service.hardware_settings.configured,
@@ -4017,6 +4113,10 @@ def main() -> None:
server.server_close()
SeatDemoHandler.speech.close()
SeatDemoHandler.assistant.close()
SeatDemoHandler.service.set_extension_control_handler(None)
SeatDemoHandler.service.set_extension_control_rejection_handler(None)
SeatDemoHandler.extension_control.close()
SeatDemoHandler.extension_toys.close()
SeatDemoHandler.pets.close()
SeatDemoHandler.nfc.close()
SeatDemoHandler.service.close()
+68
View File
@@ -277,6 +277,7 @@ class FakeVehicleService:
self.calls: list[dict[str, object]] = []
self.mode_save_calls: list[dict[str, object]] = []
self.mode_trial_calls: list[dict[str, object]] = []
self.mode_scene_publish_calls: list[dict[str, object]] = []
self.fail = False
def preview_native_can(self, **kwargs: object) -> dict[str, object]:
@@ -481,6 +482,44 @@ class FakeVehicleService:
"source_request_id": source_request_id,
}
def save_mode_profile_card(
self,
mode_id: str,
name: str,
instruction: str,
source_request_id: str,
vehicle_id: str | None = None,
) -> dict[str, object]:
return {
"status": "succeeded",
"mode_profile": {
"id": mode_id,
"name": name,
"origin": "user",
"operation_count": 1,
"instruction": instruction,
},
"vehicle_id": vehicle_id,
"storage": "persistent_yaml",
"source_request_id": source_request_id,
}
def publish_saved_mode_scene(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> dict[str, object]:
self.mode_scene_publish_calls.append(
{"mode_id": mode_id, "vehicle_id": vehicle_id}
)
return {
"state": "queued",
"topic": "from_pc/scenes",
"scene_id": mode_id,
"burst_count": 3,
"burst_window_ms": 1_000,
}
def trial_mode_profile_draft(
self,
name: str,
@@ -548,6 +587,7 @@ class TestCodexAssistantWebApi:
AssistantApiHandler.service.calls.clear()
AssistantApiHandler.service.mode_save_calls.clear()
AssistantApiHandler.service.mode_trial_calls.clear()
AssistantApiHandler.service.mode_scene_publish_calls.clear()
AssistantApiHandler.service.fail = False
FAKE_CODEX.available = True
FAKE_CODEX.turn_calls.clear()
@@ -1386,6 +1426,34 @@ class TestCodexAssistantWebApi:
assert saved["status"] == "succeeded"
assert saved["mode_profile"]["origin"] == "user"
assert saved["mode_profile"]["operation_count"] == 9
assert AssistantApiHandler.service.mode_scene_publish_calls == [
{"mode_id": "mode-movie", "vehicle_id": "denza_n7"}
]
assert saved["mode_scene_mqtt"] == {
"state": "queued",
"topic": "from_pc/scenes",
"scene_id": "mode-movie",
"burst_count": 3,
"burst_window_ms": 1_000,
}
update_status, _content_type, updated = self._request_json(
"/api/mode-profiles/mode-movie",
payload["mode_profile_draft"],
)
assert update_status == 200
assert updated["mode_profile"]["id"] == "mode-movie"
assert updated["mode_scene_mqtt"] == {
"state": "queued",
"topic": "from_pc/scenes",
"scene_id": "mode-movie",
"burst_count": 3,
"burst_window_ms": 1_000,
}
assert AssistantApiHandler.service.mode_scene_publish_calls == [
{"mode_id": "mode-movie", "vehicle_id": "denza_n7"},
{"mode_id": "mode-movie", "vehicle_id": "denza_n7"},
]
def test_vehicle_failure_is_recorded_and_never_overwritten_by_codex_success(self) -> None:
AssistantApiHandler.service.fail = True
+292 -143
View File
@@ -4,6 +4,7 @@ import json
import os
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
@@ -18,6 +19,7 @@ from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.pet_cards import PetCardService
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
from cognitive_agent.application.self_extension import (
ExtensionIntegrationError,
GatewayError,
SelfExtensionService,
SimulationGateway,
@@ -453,12 +455,55 @@ operations:
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(request, timeout=3) as response:
payload = json.loads(response.read().decode("utf-8"))
updated_scenes: list[dict[str, object]] = []
updated_at: list[float] = []
update_burst_complete = threading.Event()
def capture_updated_scene(
scene: dict[str, object],
) -> dict[str, object]:
updated_scenes.append(json.loads(json.dumps(scene)))
updated_at.append(time.monotonic())
if len(updated_scenes) == 3:
update_burst_complete.set()
return {
"delivery_status": "published",
"message_id": len(updated_scenes),
}
with patch.object(
self.service._vehicle_mqtt_transport,
"publish_mode_scene",
side_effect=capture_updated_scene,
):
with self.opener.open(request, timeout=3) as response:
payload = json.loads(response.read().decode("utf-8"))
self.assertTrue(update_burst_complete.wait(timeout=2))
self.assertEqual(response.status, 200)
self.assertEqual(payload["storage"], "persistent_yaml")
self.assertEqual(payload["mode_profile"]["origin"], "customized")
self.assertEqual(payload["mode_profile"]["instruction"], instruction)
self.assertEqual(
payload["mode_scene_mqtt"],
{
"state": "queued",
"topic": "from_pc/scenes",
"scene_id": "office",
"burst_count": 3,
"burst_window_ms": 1_000,
},
)
expected_updated_scene = next(
scene
for scene in self.service.mode_scene_catalog("denza_n7")["scenes"]
if scene["id"] == "office"
)
self.assertEqual(len(updated_scenes), 3)
self.assertLess(updated_at[-1] - updated_at[0], 1.0)
self.assertTrue(
all(scene == expected_updated_scene for scene in updated_scenes)
)
self.assertTrue(all("scenes" not in scene for scene in updated_scenes))
self.assertTrue(
(user_root / "vehicles" / "denza_n7" / "user" / "office.yaml").is_file()
)
@@ -477,11 +522,50 @@ operations:
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(create_request, timeout=3) as response:
created = json.loads(response.read().decode("utf-8"))
published_scenes: list[dict[str, object]] = []
published_at: list[float] = []
burst_complete = threading.Event()
def capture_scene(scene: dict[str, object]) -> dict[str, object]:
published_scenes.append(json.loads(json.dumps(scene)))
published_at.append(time.monotonic())
if len(published_scenes) == 3:
burst_complete.set()
return {
"delivery_status": "published",
"message_id": len(published_scenes),
}
with patch.object(
self.service._vehicle_mqtt_transport,
"publish_mode_scene",
side_effect=capture_scene,
):
with self.opener.open(create_request, timeout=3) as response:
created = json.loads(response.read().decode("utf-8"))
self.assertTrue(burst_complete.wait(timeout=2))
self.assertEqual(response.status, 201)
self.assertEqual(created["mode_profile"]["origin"], "user")
self.assertEqual(created["mode_profile"]["name"], "通勤模式")
self.assertEqual(
created["mode_scene_mqtt"],
{
"state": "queued",
"topic": "from_pc/scenes",
"scene_id": created["mode_profile"]["id"],
"burst_count": 3,
"burst_window_ms": 1_000,
},
)
expected_scene = next(
scene
for scene in self.service.mode_scene_catalog("denza_n7")["scenes"]
if scene["id"] == created["mode_profile"]["id"]
)
self.assertEqual(len(published_scenes), 3)
self.assertLess(published_at[-1] - published_at[0], 1.0)
self.assertTrue(all(scene == expected_scene for scene in published_scenes))
self.assertTrue(all("scenes" not in scene for scene in published_scenes))
self.assertTrue(
(
user_root
@@ -575,7 +659,7 @@ operations:
snapshot=lambda: {
"revision": 1,
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
"card": None,
}
)
try:
@@ -629,7 +713,17 @@ operations:
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(save_request, timeout=3) as response:
with patch.object(
self.service,
"publish_saved_mode_scene",
return_value={
"state": "queued",
"topic": "from_pc/scenes",
"scene_id": "office",
"burst_count": 3,
"burst_window_ms": 1_000,
},
), self.opener.open(save_request, timeout=3) as response:
saved = json.loads(response.read().decode("utf-8"))
execute_request = urllib.request.Request(
@@ -721,14 +815,35 @@ operations:
handler = object.__new__(SeatDemoHandler)
handler.extension = extension
handler.nfc = SimpleNamespace(
snapshot=lambda: {
"card": {"action": "present", "uid": "2CC8B103"}
}
snapshot=lambda: {"card": None}
)
profile = SimpleNamespace(
command_flow={
"function_configs": {
"local_self_extension": {
"spec": {
"domain": "self_extension",
"operations": [
{
"capability": "infrared.emit",
"target": {
"device": "tony-light-001",
"card_uid": "2CC8B103",
},
"parameters": {"action": "打开", "slot": 1},
}
],
}
}
}
}
)
entries = handler._saved_mode_infrared_entries(profile)
missing_binding = SimpleNamespace(
command_flow={
"function_configs": {
"self_extension": {
"spec": {
"domain": "self_extension",
"operations": [
@@ -743,8 +858,11 @@ operations:
}
}
)
entries = handler._saved_mode_infrared_entries(profile)
with self.assertRaisesRegex(
ExtensionIntegrationError,
"缺少持久 NFC 绑定",
):
handler._saved_mode_infrared_entries(missing_binding)
self.assertEqual([item["capability"]["slot"] for item in entries], [1])
self.assertEqual(
@@ -807,14 +925,21 @@ operations:
def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8")
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-140", timeout=3) as response:
web_console_root = REPOSITORY_ROOT / "apps" / "web-console"
template = (web_console_root / "src" / "App.vue").read_text(
encoding="utf-8"
)
vite_entry = (web_console_root / "src" / "main.js").read_text(
encoding="utf-8"
)
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-141", timeout=3) as response:
app_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
) as response:
gesture_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/console-v2.js?v=local-first-70", timeout=3
f"{self.base_url}/console-v2.js?v=local-first-76", timeout=3
) as response:
console_script = response.read().decode("utf-8")
with self.opener.open(
@@ -842,7 +967,7 @@ operations:
) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open(
f"{self.base_url}/assistant-chat.js?v=codex-assistant-33", timeout=3
f"{self.base_url}/assistant-chat.js?v=codex-assistant-34", timeout=3
) as response:
assistant_script = response.read().decode("utf-8")
with self.opener.open(
@@ -854,7 +979,7 @@ operations:
) as response:
energy_styles = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/codepen-glass-theme.css?v=creative-cloud-2", timeout=3
f"{self.base_url}/codepen-glass-theme.css?v=creative-cloud-5", timeout=3
) as response:
glass_theme = response.read().decode("utf-8")
with self.opener.open(
@@ -869,50 +994,57 @@ operations:
f"{self.base_url}/api/extension/nfc/status", timeout=3
) as response:
nfc_status = json.loads(response.read().decode("utf-8"))
with self.opener.open(
f"{self.base_url}/api/extension/control/status"
"?after_revision=-1&timeout_ms=0",
timeout=3,
) as response:
extension_control_status = json.loads(response.read().decode("utf-8"))
with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response:
runtime = json.loads(response.read().decode("utf-8"))
with self.opener.open(f"{self.base_url}/api/speech/status", timeout=3) as response:
speech_status = json.loads(response.read().decode("utf-8"))
self.assertIn("511工程-智能座舱", html)
self.assertIn('id="seatTwinCanvas"', html)
self.assertIn('data-vehicle-model="byd-tang-2024-reference"', html)
self.assertIn("整车孪生界面", html)
self.assertIn("<strong>车内灯光</strong>", html)
self.assertIn('id="interiorLightPower"', html)
self.assertIn('id="ambientBrightness"', html)
self.assertIn('id="ambientColorIndex"', html)
self.assertIn('data-function="massage"', html)
self.assertIn("CMD 0x1DE · FB 0x404 · CH1", html)
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", html)
self.assertIn('src="/app.js?v=dual-seat-140"', html)
self.assertIn('href="/styles.css?v=dual-seat-23"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
self.assertNotIn('class="vehicle-system-hud"', html)
self.assertIn('src="/console-v2.js?v=local-first-70"', html)
self.assertIn('href="/console-v2.css?v=delivery-state-69"', html)
self.assertIn('id="seatTwinCanvas"', template)
self.assertIn('data-vehicle-model="byd-tang-2024-reference"', template)
self.assertIn("整车孪生界面", template)
self.assertIn("<strong>车内灯光</strong>", template)
self.assertIn('id="interiorLightPower"', template)
self.assertIn('id="ambientBrightness"', template)
self.assertIn('id="ambientColorIndex"', template)
self.assertIn('data-function="massage"', template)
self.assertIn("CMD 0x1DE · FB 0x404 · CH1", template)
self.assertIn("CMD 0x431 · FB 0x41A · CH1", template)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", template)
self.assertNotIn("透明车体 · 实体设备", template)
self.assertIn('src="/app.js?v=dual-seat-141"', html)
self.assertIn('import "../styles.css";', vite_entry)
self.assertNotIn('class="vehicle-system-hud"', template)
self.assertIn('src="/console-v2.js?v=local-first-76"', html)
self.assertIn('import "../console-v2.css";', vite_entry)
self.assertIn('src="/speech-input.js?v=sensevoice-13"', html)
self.assertIn('href="/speech-input.css?v=sensevoice-7"', html)
self.assertIn('id="speechDictationButton"', html)
self.assertIn('class="speech-waveform"', html)
self.assertIn('import "../speech-input.css";', vite_entry)
self.assertIn('id="speechDictationButton"', template)
self.assertIn('class="speech-waveform"', template)
self.assertIn('<body class="console-v2">', html)
self.assertIn('id="v2BootScreen"', html)
self.assertIn('正在加载车机控制与实时状态', html)
self.assertIn('id="v2BootScreen"', template)
self.assertIn('正在加载车机控制与实时状态', template)
self.assertIn('src="/self-extension.js?v=pet-settings-52"', html)
self.assertIn('href="/self-extension.css?v=pet-settings-52"', html)
self.assertNotIn("self-calibration", html)
self.assertNotIn("self-repair", html)
self.assertNotIn("self-evolution", html)
self.assertNotIn("滚动查看更多", html)
self.assertIn('aria-label="发送消息"', html)
self.assertIn('id="assistantTranscript"', html)
self.assertIn('href="/assistant-chat.css?v=codex-assistant-29"', html)
self.assertIn('src="/assistant-chat.js?v=codex-assistant-33"', html)
self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html)
self.assertIn('import "../self-extension.css";', vite_entry)
self.assertNotIn("self-calibration", template)
self.assertNotIn("self-repair", template)
self.assertNotIn("self-evolution", template)
self.assertNotIn("滚动查看更多", template)
self.assertIn('aria-label="发送消息"', template)
self.assertIn('id="assistantTranscript"', template)
self.assertIn('import "../assistant-chat.css";', vite_entry)
self.assertIn('src="/assistant-chat.js?v=codex-assistant-34"', html)
self.assertIn('import "../energy-monitor.css";', vite_entry)
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
self.assertIn('href="/codepen-glass-theme.css?v=creative-cloud-2"', html)
self.assertIn('import "../codepen-glass-theme.css";', vite_entry)
self.assertEqual(extension_control_status["source_label"], "车端 MQTT")
self.assertIn("action_statuses", extension_control_status)
self.assertIn("--glass-shell:", glass_theme)
self.assertIn("backdrop-filter: blur(22px) saturate(1.25)", glass_theme)
self.assertNotIn('"admin"', console_script)
@@ -984,7 +1116,7 @@ operations:
self.assertIn('const DEFAULT_EXECUTION_MODE = "hardware"', app_script)
self.assertIn('preferredExecutionMode: DEFAULT_EXECUTION_MODE', app_script)
self.assertIn('selectExecutionMode(modeAvailable ? preferredMode : "dry_run", { remember: false })', app_script)
self.assertIn('class="mode-option active" id="hardwareMode"', html)
self.assertIn('class="mode-option active" id="hardwareMode"', template)
self.assertIn('grid-template-columns: clamp(245px, 18.5vw, 310px)', console_styles)
self.assertNotIn('.v2-control-switch {', console_styles)
self.assertIn('.v2-module-switcher {', console_styles)
@@ -1036,12 +1168,12 @@ operations:
self.assertNotIn('.v2-config-section', console_styles)
self.assertNotIn('.console-v2 .v2-config-card {', console_styles)
self.assertIn('.console-v2.v2-sidebar-collapsed .workspace', console_styles)
self.assertNotIn('id="hardwareAccessField"', html)
self.assertNotIn('id="hardwareClientLabel"', html)
self.assertNotIn('id="requestHardwareAccess"', html)
self.assertNotIn('id="releaseHardwareAccess"', html)
self.assertNotIn('id="approvalField"', html)
self.assertNotIn('id="approvalToken"', html)
self.assertNotIn('id="hardwareAccessField"', template)
self.assertNotIn('id="hardwareClientLabel"', template)
self.assertNotIn('id="requestHardwareAccess"', template)
self.assertNotIn('id="releaseHardwareAccess"', template)
self.assertNotIn('id="approvalField"', template)
self.assertNotIn('id="approvalToken"', template)
self.assertNotIn("approval_token: approvalToken", app_script)
self.assertNotIn("授权并真实执行", app_script)
self.assertIn('const dynamicOrder = new Map([["rocking_horse", 0]', app_script)
@@ -1052,19 +1184,19 @@ operations:
'if (mode === "hardware" && !hardwareExecutionReady) return false',
app_script,
)
self.assertIn('data-control-transport="wired_can"', html)
self.assertIn('data-control-transport="wireless_mqtt"', html)
self.assertIn('<div class="mode-rail" aria-label="功能模式">', html)
self.assertIn('id="modeRailToggle"', html)
self.assertIn('id="modeRailContent"', html)
self.assertIn('role="region"', html)
self.assertIn('aria-label="功能模式列表"', html)
self.assertIn('<span>AI 新建</span>', html)
self.assertIn('class="mode-rail-identity"', html)
self.assertIn('aria-expanded="true"', html)
self.assertIn('模拟下发', html)
self.assertIn('真实下发', html)
self.assertNotIn('id="simulationMode"', html)
self.assertIn('data-control-transport="wired_can"', template)
self.assertIn('data-control-transport="wireless_mqtt"', template)
self.assertIn('<div class="mode-rail" aria-label="功能模式">', template)
self.assertIn('id="modeRailToggle"', template)
self.assertIn('id="modeRailContent"', template)
self.assertIn('role="region"', template)
self.assertIn('aria-label="功能模式列表"', template)
self.assertIn('<span>AI 新建</span>', template)
self.assertIn('class="mode-rail-identity"', template)
self.assertIn('aria-expanded="true"', template)
self.assertIn('模拟下发', template)
self.assertIn('真实下发', template)
self.assertNotIn('id="simulationMode"', template)
self.assertIn('control_transport: state.controlTransport', app_script)
self.assertIn('button.dataset.connectionState = connectionState', app_script)
self.assertIn('信号链路${connectionState === "online" ? "正常" : "断开"}', app_script)
@@ -1097,16 +1229,16 @@ operations:
)
self.assertNotIn(
'id="hardwareMode" data-execution-mode="hardware" disabled',
html,
template,
)
self.assertIn('const seat = twin.selected === "driver" ? "driver" : "passenger"', app_script)
self.assertNotIn('data-mode-command="seat driver vent low"', app_script)
self.assertIn("CH0 副驾座椅与 CH1 主驾座椅独立收发、独立反馈", app_script)
self.assertIn("CH1 主驾实车测试不启用无闭环的座椅位置手势", app_script)
self.assertNotIn("车端 CAN 未启用,其他领域保持虚拟执行", app_script)
self.assertNotIn('id="hardwareNote"', html)
self.assertNotIn('id="hardwareAliasNote"', html)
self.assertNotIn('id="vehicleTransportNote"', html)
self.assertNotIn('id="hardwareNote"', template)
self.assertNotIn('id="hardwareAliasNote"', template)
self.assertNotIn('id="vehicleTransportNote"', template)
self.assertIn("下发控制指令", console_script)
self.assertIn('new Set(["mode_editor_compile"])', console_script)
self.assertIn("function extractReferenceWindowPanes", app_script)
@@ -1198,14 +1330,17 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v305"', service_worker)
self.assertIn(
'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"',
service_worker,
)
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
self.assertIn('/console-v2.js?v=local-first-70', service_worker)
self.assertIn('/console-v2.js?v=local-first-76', service_worker)
self.assertIn('/assistant-chat.css?v=codex-assistant-29', service_worker)
self.assertIn('/assistant-chat.js?v=codex-assistant-33', service_worker)
self.assertIn('/assistant-chat.js?v=codex-assistant-34', service_worker)
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker)
self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker)
self.assertIn('"/app.js?v=dual-seat-140"', service_worker)
self.assertIn('"/app.js?v=dual-seat-141"', service_worker)
self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker)
self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker)
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker)
@@ -1264,39 +1399,42 @@ operations:
self.assertIn('error.code === "hardware_busy"', app_script)
self.assertIn("BENCH BUSY", app_script)
self.assertIn("FBXLoader.js", html)
self.assertIn('data-seat-layout="five-seat"', html)
self.assertIn('data-seat-layout="five-seat"', template)
self.assertIn('data-active-seat="front-right"', html)
self.assertNotIn('placeholder="请输入服务端配置令牌"', html)
self.assertNotIn('class="twin-callout-layer"', html)
self.assertNotIn('class="twin-callout', html)
self.assertNotIn('data-callout=', html)
self.assertIn('data-twin-function="ventilation"', html)
self.assertIn('data-twin-function="heating"', html)
self.assertIn('class="twin-level-meter"', html)
self.assertIn('id="presetIndicator"', html)
self.assertIn('id="vehicleSelect"', html)
self.assertIn('id="saveModeToggle"', html)
self.assertIn("AI 新建", html)
self.assertIn('aria-label="快捷功能模式,可横向滚动"', html)
self.assertIn('id="modeRailContent"', html)
self.assertIn('aria-label="功能模式列表"', html)
self.assertNotIn('id="modeRailPrev"', html)
self.assertNotIn('id="modeRailNext"', html)
self.assertGreater(html.index('id="saveModeToggle"'), html.index('id="modeRailContent"'))
self.assertIn('id="modeGalleryToggle"', html)
self.assertIn('id="modeGallery"', html)
self.assertIn('id="modeGalleryGroups"', html)
self.assertIn("管理功能模式", html)
self.assertIn('id="modeEditorBackdrop"', html)
self.assertIn('id="modeEditorName"', html)
self.assertIn('id="modeEditorInstruction"', html)
self.assertIn('id="modeEditorExecute"', html)
self.assertIn('id="modeEditorComplete"', html)
self.assertIn('id="modeEditorFlowPreview"', html)
self.assertIn('class="mode-editor-flow-preview empty"', html)
self.assertNotIn('id="saveModeSource"', html)
self.assertNotIn("主驾 24℃", html)
self.assertIn('data-active-seat="front-right"', template)
self.assertNotIn('placeholder="请输入服务端配置令牌"', template)
self.assertNotIn('class="twin-callout-layer"', template)
self.assertNotIn('class="twin-callout', template)
self.assertNotIn('data-callout=', template)
self.assertIn('data-twin-function="ventilation"', template)
self.assertIn('data-twin-function="heating"', template)
self.assertIn('class="twin-level-meter"', template)
self.assertIn('id="presetIndicator"', template)
self.assertIn('id="vehicleSelect"', template)
self.assertIn('id="saveModeToggle"', template)
self.assertIn("AI 新建", template)
self.assertIn('aria-label="快捷功能模式,可横向滚动"', template)
self.assertIn('id="modeRailContent"', template)
self.assertIn('aria-label="功能模式列表"', template)
self.assertNotIn('id="modeRailPrev"', template)
self.assertNotIn('id="modeRailNext"', template)
self.assertGreater(
template.index('id="saveModeToggle"'),
template.index('id="modeRailContent"'),
)
self.assertIn('id="modeGalleryToggle"', template)
self.assertIn('id="modeGallery"', template)
self.assertIn('id="modeGalleryGroups"', template)
self.assertIn("管理功能模式", template)
self.assertIn('id="modeEditorBackdrop"', template)
self.assertIn('id="modeEditorName"', template)
self.assertIn('id="modeEditorInstruction"', template)
self.assertIn('id="modeEditorExecute"', template)
self.assertIn('id="modeEditorComplete"', template)
self.assertIn('id="modeEditorFlowPreview"', template)
self.assertIn('class="mode-editor-flow-preview empty"', template)
self.assertNotIn('id="saveModeSource"', template)
self.assertNotIn("主驾 24℃", template)
populate_modes = app_script[
app_script.index("function populateModeChips()")
: app_script.index("function setModeExecutionState")
@@ -1345,21 +1483,21 @@ operations:
self.assertIn("message.payload", app_script)
self.assertIn("source_request_id: editor.sourceRequestId", app_script)
self.assertIn('method: "POST"', app_script)
self.assertIn('data-domain-panel="climate"', html)
self.assertIn('data-domain-panel="roof"', html)
self.assertIn('data-domain-panel="ambient_light"', html)
self.assertIn('data-domain-panel="window"', html)
self.assertIn('data-domain-panel="front_light"', html)
self.assertIn('data-domain-panel="vehicle_status"', html)
self.assertIn('id="vehicleGear"', html)
self.assertIn('id="vehicleSpeed"', html)
self.assertNotIn('data-domain-panel="control_link"', html)
self.assertIn('id="cabinStateTitle">整车设备状态</h3>', html)
self.assertIn('data-domain-panel="climate"', template)
self.assertIn('data-domain-panel="roof"', template)
self.assertIn('data-domain-panel="ambient_light"', template)
self.assertIn('data-domain-panel="window"', template)
self.assertIn('data-domain-panel="front_light"', template)
self.assertIn('data-domain-panel="vehicle_status"', template)
self.assertIn('id="vehicleGear"', template)
self.assertIn('id="vehicleSpeed"', template)
self.assertNotIn('data-domain-panel="control_link"', template)
self.assertIn('id="cabinStateTitle">整车设备状态</h3>', template)
self.assertIn('id="cabinCarouselPrev"', app_script)
self.assertIn('id="cabinCarouselNext"', app_script)
self.assertIn('aria-label="左右浏览设备卡片"', app_script)
self.assertNotIn('class="cabin-category-tabs"', app_script)
self.assertNotIn('class="section-kicker"', html)
self.assertNotIn('class="section-kicker"', template)
self.assertIn('.joint-twin-shell > .twin-band {', console_styles)
self.assertIn('grid-template-rows: 62px minmax(0, 1fr)', console_styles)
self.assertIn('grid-template-columns: repeat(2, minmax(0, 1fr))', console_styles)
@@ -1394,18 +1532,18 @@ operations:
self.assertIn('name: "遮阳帘", measurable: true, components: []', app_script)
self.assertIn("仅支持设备总功率", app_script)
self.assertIn('.device-power-drawer {', console_styles)
self.assertNotIn('class="roof-visual"', html)
self.assertNotIn('class="ambient-visual"', html)
self.assertIn('id="driverAirDirection"', html)
self.assertIn('id="passengerAirDirection"', html)
self.assertNotIn('id="transmittanceValue"', html)
self.assertNotIn('id="roofMotorState"', html)
self.assertIn('id="ambientBrightness"', html)
self.assertNotIn('class="example-group"', html)
self.assertNotIn('id="safetyStrip"', html)
self.assertNotIn('class="pipeline"', html)
self.assertNotIn('class="topbar"', html)
self.assertNotIn('data-axis="height"', html)
self.assertNotIn('class="roof-visual"', template)
self.assertNotIn('class="ambient-visual"', template)
self.assertIn('id="driverAirDirection"', template)
self.assertIn('id="passengerAirDirection"', template)
self.assertNotIn('id="transmittanceValue"', template)
self.assertNotIn('id="roofMotorState"', template)
self.assertIn('id="ambientBrightness"', template)
self.assertNotIn('class="example-group"', template)
self.assertNotIn('id="safetyStrip"', template)
self.assertNotIn('class="pipeline"', template)
self.assertNotIn('class="topbar"', template)
self.assertNotIn('data-axis="height"', template)
self.assertEqual(runtime["domain"], "cabin")
self.assertEqual(
runtime["hardware_execution_allowed"],
@@ -1662,7 +1800,7 @@ operations:
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/app.js?v=dual-seat-140", timeout=3
f"{self.base_url}/app.js?v=dual-seat-141", timeout=3
) as response:
app_script = response.read().decode("utf-8")
@@ -1946,7 +2084,7 @@ operations:
finally:
SeatDemoHandler.extension = original_extension
def test_main_request_routes_live_nfc_peripheral_to_gscu(self) -> None:
def test_main_request_routes_durable_nfc_peripheral_after_card_removal(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
SeatDemoHandler.extension = SelfExtensionService(
@@ -1959,7 +2097,7 @@ operations:
snapshot=lambda: {
"revision": 1,
"reader": {"online": True},
"card": {"action": "present", "uid": "1A53DD95"},
"card": None,
}
)
catalog_request = urllib.request.Request(
@@ -2021,7 +2159,7 @@ operations:
self.assertEqual(operation["domain"], "self_extension")
self.assertTrue(operation["telemetry"]["gateway_acknowledged"])
def test_mode_creation_cannot_be_claimed_by_live_nfc_peripheral(self) -> None:
def test_mode_creation_cannot_be_claimed_by_bound_nfc_peripheral(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
with tempfile.TemporaryDirectory() as directory:
@@ -2050,7 +2188,7 @@ operations:
nfc_snapshot = {
"revision": 1,
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
"card": None,
}
utterance = "创建一个打开闪灯玩具模式"
partition = partition_active_peripheral_intent(
@@ -2352,14 +2490,20 @@ operations:
]
self.assertTrue(validated_payloads)
self.assertTrue(
all(
any(
{"native_climate", "self_extension"}.issubset(
payload["function_configs"]
)
for payload in validated_payloads
)
)
first_validated = validated_payloads[0]
first_validated = next(
payload
for payload in validated_payloads
if {"native_climate", "self_extension"}.issubset(
payload["function_configs"]
)
)
self.assertTrue(
{"native_climate", "self_extension"}.issubset(
first_validated["command_plans"]
@@ -2605,11 +2749,11 @@ operations:
self.assertEqual(events[-1]["type"], "error")
self.assertEqual(events[-1]["error"], "can_transport_failed")
validated = next(
validated = [
item["payload"]
for item in events
if item["type"] == "validated_commands"
)
][-1]
self.assertEqual(validated["status"], "processing")
self.assertEqual(
set(validated["function_configs"]),
@@ -3620,16 +3764,21 @@ operations:
"provider": "local",
"conversation_id": "conv-e2e-hardware-locked",
"execution_mode": "hardware",
"hardware_client_id": "hwc-e2e-locked-client",
"approval_token": "must-not-be-persisted",
}
).encode("utf-8"),
)
with self.assertRaises(urllib.error.HTTPError) as caught:
with patch.object(
SeatDemoHandler,
"_hardware_access_bypass_allowed",
return_value=False,
), self.assertRaises(urllib.error.HTTPError) as caught:
self.opener.open(request, timeout=3)
self.assertEqual(caught.exception.code, 403)
payload = json.loads(caught.exception.read().decode("utf-8"))
self.assertEqual(payload["error"], "hardware_safety_gate")
self.assertEqual(payload["error"], "hardware_access_required")
artifact_text = "\n".join(
path.read_text(encoding="utf-8")
for path in Path(self.temporary_directory.name).rglob("*")
@@ -778,7 +778,7 @@ def test_preview_and_process_share_mode_target_safety_gate(
} == {"mode_target_scope_mismatch"}
def test_active_nfc_peripheral_can_execute_but_inactive_peripheral_is_rejected(
def test_durable_nfc_peripheral_can_execute_after_bound_card_is_removed(
vehicle_service: SeatDemoService,
) -> None:
learned = {
@@ -798,10 +798,10 @@ def test_active_nfc_peripheral_can_execute_but_inactive_peripheral_is_rejected(
peripheral_provider=lambda: [learned],
nfc_provider=lambda: {"card": {"uid": "a1b2c3d4"}},
)
inactive_catalog = AssistantCapabilityCatalog(
detached_catalog = AssistantCapabilityCatalog(
vehicle_service,
peripheral_provider=lambda: [learned],
nfc_provider=lambda: {"card": {"uid": "different-card"}},
nfc_provider=lambda: {"card": None},
)
active_client = _ToolCallingCodexClient()
@@ -834,27 +834,37 @@ def test_active_nfc_peripheral_can_execute_but_inactive_peripheral_is_rejected(
)
assert executed == ["打开星空投影仪"]
inactive_client = _ToolCallingCodexClient()
inactive_assistant = CodexAssistantService(inactive_client)
errors: list[str] = []
def attempt_inactive(token: str, _turn: ActiveCodexTurn) -> None:
try:
inactive_assistant.execute_cockpit_request(token)
except CodexVehicleToolError as exc:
errors.append(str(exc))
inactive_client.on_turn = attempt_inactive
_stream_with_catalog(
inactive_assistant,
inactive_catalog,
"打开星空投影仪",
execute_control=lambda _instruction, _progress: pytest.fail(
"inactive NFC peripheral must not execute"
),
conversation_id="conv-inactive-peripheral-01",
detached_client = _ToolCallingCodexClient()
detached_assistant = CodexAssistantService(detached_client)
detached_executed: list[str] = []
detached_client.on_turn = (
lambda token, _turn: detached_assistant.execute_cockpit_request(token)
)
assert errors == ["vehicle control was not authorized by the original user request"]
_stream_with_catalog(
detached_assistant,
detached_catalog,
"打开星空投影仪",
execute_control=lambda instruction, _progress: (
detached_executed.append(instruction)
or {
"status": "succeeded",
"execution_mode": "dry_run",
"utterance": instruction,
"infrared_actions": [
{
"capability_id": "star-projector-on",
"device_id": "star-projector",
"device_name": "星空投影仪",
"action": "开启",
"slot": 7,
"transport": "infrared",
}
],
}
),
conversation_id="conv-detached-peripheral-01",
)
assert detached_executed == ["打开星空投影仪"]
@pytest.mark.parametrize(
@@ -451,7 +451,7 @@ def test_mode_provider_failure_degrades_to_unknown_instead_of_empty(
assert "0 个" not in answer.answer
def test_learned_peripheral_requires_enabled_capability_and_matching_nfc_card(
def test_learned_peripheral_requires_enabled_capability_and_durable_nfc_binding(
vehicle_service: SeatDemoService,
) -> None:
capability = {
@@ -470,8 +470,8 @@ def test_learned_peripheral_requires_enabled_capability_and_matching_nfc_card(
absent_item = absent["learned_peripherals"][0]
assert absent_item["enabled"] is True
assert absent_item["active_for_current_nfc_card"] is False
assert absent_item["assistant_callable"] is False
assert "闪灯玩具" not in _catalog(
assert absent_item["assistant_callable"] is True
assert "闪灯玩具" in _catalog(
vehicle_service, peripherals=lambda: [capability]
).control_terms(absent)
@@ -910,6 +910,39 @@ def test_provider_failure_is_not_misreported_as_an_empty_catalog(
assert "没有" not in answer.answer
def test_nfc_reader_failure_does_not_hide_durable_peripheral_bindings(
vehicle_service: SeatDemoService,
) -> None:
capability = {
"id": "lamp-on",
"status": "learned",
"enabled": True,
"device_id": "lamp-1",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具"],
"action": "开启",
"action_names": ["打开"],
"card_uid": "A1B2",
"slot": 1,
}
def nfc_unavailable() -> dict[str, object]:
raise RuntimeError("reader offline")
catalog = _catalog(
vehicle_service,
peripherals=lambda: [capability],
nfc=nfc_unavailable,
)
snapshot = catalog.snapshot()
item = snapshot["learned_peripherals"][0]
assert snapshot["source_status"]["learned_peripherals"] == "ready"
assert item["active_for_current_nfc_card"] is False
assert item["assistant_callable"] is True
assert "闪灯玩具" in catalog.control_terms(snapshot)
def test_catalog_revision_is_stable_and_changes_with_live_capabilities(
vehicle_service: SeatDemoService,
) -> None:
+58 -13
View File
@@ -3,25 +3,35 @@ from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / "apps" / "web-console"
def vue_shell() -> str:
return (WEB / "src" / "App.vue").read_text(encoding="utf-8")
def test_console_contains_ai_assistant_surface():
html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8")
assert 'id="assistantTranscript"' in html
assert 'id="assistantMessages"' in html
assert "你好,我是 AI助手。你可以向我提问" in html
assert 'aria-label="AI助手对话"' in html
assert 'placeholder="向 AI助手提问或交代工作"' in html
assert 'aria-label="发送消息"' in html
assert 'href="/assistant-chat.css?v=codex-assistant-29"' in html
assert 'src="/assistant-chat.js?v=codex-assistant-33"' in html
html = (WEB / "index.html").read_text(encoding="utf-8")
template = vue_shell()
vite_entry = (WEB / "src" / "main.js").read_text(encoding="utf-8")
assert 'id="assistantTranscript"' in template
assert 'id="assistantMessages"' in template
assert "你好,我是 AI助手。你可以向我提问" in template
assert 'aria-label="AI助手对话"' in template
assert 'placeholder="向 AI助手提问或交代工作"' in template
assert 'aria-label="发送消息"' in template
assert 'import "../assistant-chat.css";' in vite_entry
assert 'src="/assistant-chat.js?v=codex-assistant-34"' in html
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
assert 'const CACHE_VERSION = "cognitive-agent-pad-v305"' in service_worker
assert (
'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"'
in service_worker
)
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
assert '"/assistant-chat.js?v=codex-assistant-33"' in service_worker
assert '"/assistant-chat.js?v=codex-assistant-34"' in service_worker
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
@@ -90,6 +100,18 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card():
assert "appendDispatchedControls(message, event.payload)" in script
assert "function announceModeExecution(" in script
assert "announceModeExecution," in script
assert "function beginModeExecution(" in script
assert "function completeModeExecution(" in script
assert "appendDispatchedControls(message, modeExecutionPreviewResult(profile))" in script
assert "beginModeExecution," in script
assert "completeModeExecution," in script
assert "const operationByRef = new Map()" in script
assert 'deliveryState: "submitting"' in script
assert 'deliveryLabel: "正在下发"' in script
assert '"seat.set_cushion_tilt": "坐盆"' in script
assert '"seat.set_backrest_recline": "靠背"' in script
assert '"seat.set_legrest_height": "腿托"' in script
assert '"seat.set_legrest_extension": "腿托"' in script
assert '"ambient_light.set_illumination": "氛围灯颜色与亮度"' in script
assert '.replaceAll("_", "-")' in script
assert ".assistant-dispatched-controls > summary" in css
@@ -98,6 +120,29 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card():
assert ".assistant-dispatched-controls[open]" in css
def test_saved_mode_execution_opens_with_pending_flow_before_http_completion():
app = (ROOT / "apps/web-console/app.js").read_text(encoding="utf-8")
flow = app.split(
"async function executeSavedModeProfile", 1
)[1].split("function applyModeCommand", 1)[0]
begin_index = flow.index("assistant?.beginModeExecution?.(mode")
request_index = flow.index("await postControlPhase(")
assert begin_index < request_index
assert "assistant.completeModeExecution(assistantModeHandle, mode, payload" in flow
assert "assistant.completeModeExecution(assistantModeHandle, mode, null" in flow
def test_seat_control_cards_name_the_controlled_seat_component():
console = (ROOT / "apps/web-console/console-v2.js").read_text(encoding="utf-8")
assert '"seat.set_cushion_tilt": ["副驾座椅坐盆调节"' in console
assert 'backrest: "主驾座椅靠背调节"' in console
assert 'basin: "主驾座椅坐盆调节"' in console
assert 'legrest: "主驾座椅腿托调节"' in console
assert 'horizontal: "主驾座椅前后调节"' in console
def test_codex_request_keeps_trusted_vehicle_context_without_legacy_model_routing():
script = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
request_body = script.split("function requestBody(message)", 1)[1].split(
@@ -332,7 +377,7 @@ def test_assistant_composer_grows_to_a_cap_then_scrolls_its_own_content():
def test_assistant_composer_keeps_the_microphone_visible_and_aligned():
html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8")
html = vue_shell()
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
assert 'id="speechDictationButton"' in html
assert 'data-lucide="mic"' in html
@@ -345,7 +390,7 @@ def test_assistant_composer_keeps_the_microphone_visible_and_aligned():
def test_voice_input_supports_direct_manual_capture_and_background_wake_standby():
html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8")
html = vue_shell()
script = (ROOT / "apps/web-console/speech-input.js").read_text(encoding="utf-8")
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
@@ -27,18 +27,23 @@ def node_runtime() -> str:
def test_energy_monitor_assets_are_versioned_and_cached() -> None:
html = source("index.html")
vite_entry = source("src/main.js")
service_worker = source("service-worker.js")
for asset in (
"/energy-monitor.css?v=energy-monitor-4",
"/energy-monitor.js?v=energy-monitor-4",
):
assert asset in html
assert f'"{asset}"' in service_worker
assert html.index("/assistant-chat.js?v=codex-assistant-33") < html.index(
assert 'import "../energy-monitor.css";' in vite_entry
assert "/energy-monitor.js?v=energy-monitor-4" in html
assert html.index("/assistant-chat.js?v=codex-assistant-34") < html.index(
"/energy-monitor.js?v=energy-monitor-4"
)
assert 'const CACHE_VERSION = "cognitive-agent-pad-v305"' in service_worker
assert (
'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"'
in service_worker
)
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
+363
View File
@@ -0,0 +1,363 @@
from __future__ import annotations
import json
import tempfile
import time
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
from cognitive_agent.application.extension_control import (
ExtensionControlAuthorizationError,
ExtensionControlConsumer,
ExtensionControlError,
authorize_extension_control_actions,
parse_extension_control_payload,
)
from cognitive_agent.application.self_extension import (
ExtensionIntegrationError,
SelfExtensionService,
SimulationGateway,
)
def wait_until(predicate, timeout: float = 1.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.005)
return bool(predicate())
def action(
slot: int,
action_name: str,
*,
target_scope: str = "toy-light-001",
at_ms: int = 0,
**extra: Any,
) -> dict[str, Any]:
return {
"type": "infrared_emit",
"at_ms": at_ms,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": target_scope,
"operation": "emit",
"parameters": {"action": action_name, "slot": slot},
**extra,
}
def request(*actions: dict[str, Any]) -> dict[str, Any]:
return {
"domain": "self_extension",
"operation_count": len(actions),
"actions": list(actions),
}
def capability(
capability_id: str,
slot: int,
action_name: str,
*,
device_id: str = "toy-light-001",
enabled: bool = True,
card_uid: str = "E113B203",
) -> dict[str, Any]:
return {
"id": capability_id,
"status": "learned",
"enabled": enabled,
"device_id": device_id,
"device_name": "闪烁灯",
"card_uid": card_uid,
"action": action_name,
"slot": slot,
"risk": "low",
}
def encoded(payload: dict[str, Any]) -> bytes:
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
def test_parser_accepts_scene_action_shape_and_strips_compatibility_fields() -> None:
payload = request(
action(
2,
"打开",
target={"device": "toy-light-001", "card_uid": "E113B203"},
operation_ref="toy-light-on",
)
)
parsed = parse_extension_control_payload(encoded(payload))
assert parsed == request(action(2, "打开"))
assert "target" not in parsed["actions"][0]
assert "operation_ref" not in parsed["actions"][0]
@pytest.mark.parametrize(
("payload", "message"),
[
(
{
"domain": "self_extension",
"operation_count": 2,
"actions": [action(2, "打开")],
},
"must equal",
),
(request({**action(2, "打开"), "domain": "seat"}), "self_extension"),
(request({**action(2, "打开"), "operation": "set"}), "must be emit"),
(request(action(2, "打开", target_scope="闪烁灯")), "English device id"),
],
)
def test_parser_rejects_invalid_control_contract(
payload: dict[str, Any],
message: str,
) -> None:
with pytest.raises(ExtensionControlError, match=message):
parse_extension_control_payload(encoded(payload))
def test_parser_rejects_duplicate_json_keys() -> None:
raw = (
b'{"domain":"self_extension","domain":"self_extension",'
b'"operation_count":1,"actions":[]}'
)
with pytest.raises(ExtensionControlError, match="duplicate key"):
parse_extension_control_payload(raw)
def test_authorization_requires_exact_enabled_learned_bound_ability() -> None:
parsed = parse_extension_control_payload(encoded(request(action(2, "打开"))))
valid = capability("light-on", 2, "打开")
[authorized] = authorize_extension_control_actions(parsed, [valid])
assert authorized["capability_id"] == "light-on"
assert authorized["action"] == action(2, "打开")
for disallowed in (
[{**valid, "enabled": False}],
[{**valid, "status": "draft"}],
[{**valid, "card_uid": ""}],
[{**valid, "device_id": "another-device"}],
[{**valid, "action": "关闭"}],
[{**valid, "slot": 3}],
):
with pytest.raises(ExtensionControlAuthorizationError, match="not authorized"):
authorize_extension_control_actions(parsed, disallowed)
def test_consumer_validates_entire_request_then_executes_by_at_ms() -> None:
catalog = [
capability("light-on", 2, "打开"),
capability("light-red", 3, "切换为红色"),
]
executions: list[tuple[float, dict[str, Any]]] = []
def execute(value: dict[str, Any]) -> dict[str, Any]:
executions.append((time.monotonic(), value))
return {"success": True, "transport": "tcp", "gateway_acknowledged": True}
consumer = ExtensionControlConsumer(
catalog_provider=lambda: catalog,
executor=execute,
adjacent_emit_interval_s=0,
)
consumer.start()
try:
consumer.submit(
encoded(
request(
action(2, "打开", at_ms=30),
action(3, "切换为红色", at_ms=0),
)
)
)
assert wait_until(lambda: consumer.snapshot()["state"] == "completed")
assert [item[1]["parameters"]["slot"] for item in executions] == [3, 2]
status = consumer.snapshot()
assert status["processed_count"] == 1
assert status["executed_action_count"] == 2
assert [item["slot"] for item in status["last_results"]] == [3, 2]
assert [item["slot"] for item in status["action_statuses"]] == [3, 2]
assert [item["state"] for item in status["action_statuses"]] == [
"submitted_unverified",
"submitted_unverified",
]
assert all(
item["state_label"] == "已下发 · 物理效果未确认"
for item in status["action_statuses"]
)
assert status["source_label"] == "车端 MQTT"
assert status["active_action_index"] is None
assert status["last_error"] is None
finally:
consumer.close()
def test_consumer_rejects_whole_request_before_first_tcp_command() -> None:
executions: list[dict[str, Any]] = []
consumer = ExtensionControlConsumer(
catalog_provider=lambda: [capability("light-on", 2, "打开")],
executor=lambda value: executions.append(value) or {"success": True},
adjacent_emit_interval_s=0,
)
consumer.start()
try:
consumer.submit(
encoded(
request(
action(2, "打开"),
action(3, "切换为红色"),
)
)
)
assert wait_until(lambda: consumer.snapshot()["state"] == "rejected")
assert executions == []
assert consumer.snapshot()["rejected_count"] == 1
assert "not authorized" in consumer.snapshot()["last_error"]
finally:
consumer.close()
def test_consumer_aborts_remaining_actions_after_tcp_failure() -> None:
catalog = [
capability("light-on", 2, "打开"),
capability("light-red", 3, "切换为红色"),
capability("light-blue", 5, "切换为蓝色"),
]
attempted_slots: list[int] = []
def execute(value: dict[str, Any]) -> dict[str, Any]:
slot = int(value["parameters"]["slot"])
attempted_slots.append(slot)
if slot == 3:
raise RuntimeError("TCP disconnected")
return {"success": True, "transport": "tcp"}
consumer = ExtensionControlConsumer(
catalog_provider=lambda: catalog,
executor=execute,
adjacent_emit_interval_s=0,
)
consumer.start()
try:
consumer.submit(
encoded(
request(
action(2, "打开"),
action(3, "切换为红色"),
action(5, "切换为蓝色"),
)
)
)
assert wait_until(lambda: consumer.snapshot()["state"] == "failed")
assert attempted_slots == [2, 3]
status = consumer.snapshot()
assert status["failed_count"] == 1
assert status["executed_action_count"] == 1
assert [item["state"] for item in status["action_statuses"]] == [
"submitted_unverified",
"failed",
"aborted",
]
assert status["last_error"] == "TCP disconnected"
finally:
consumer.close()
def test_consumer_projects_mqtt_transport_rejection_without_execution() -> None:
executions: list[dict[str, Any]] = []
consumer = ExtensionControlConsumer(
catalog_provider=list,
executor=lambda value: executions.append(value) or {"success": True},
)
consumer.start()
try:
consumer.reject_transport_message(
"retained extension/ctl messages are not executable",
"retained",
)
status = consumer.snapshot()
assert status["state"] == "rejected"
assert status["request_count"] == 1
assert status["processed_count"] == 1
assert status["rejected_count"] == 1
assert status["last_rejection_kind"] == "retained"
assert status["last_latency_ms"] == 0
assert status["action_statuses"] == []
assert executions == []
finally:
consumer.close()
def test_self_extension_reauthorizes_immediately_before_gateway_emit() -> None:
with tempfile.TemporaryDirectory() as directory:
service = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(directory, "infrared.sqlite3"),
)
service.sync_peripheral_catalog([capability("light-on", 2, "打开")])
with patch.object(
service,
"execute_gateway",
return_value={
"success": True,
"transport": "tcp",
"gateway_acknowledged": True,
},
) as execute_gateway:
result = service.execute_bound_infrared_action(action(2, "打开"))
execute_gateway.assert_called_once_with("emit", {"slot": 2})
assert result["authorization"]["capability_id"] == "light-on"
with pytest.raises(ExtensionIntegrationError, match="未通过"):
service.execute_bound_infrared_action(action(3, "切换为红色"))
assert execute_gateway.call_count == 1
service.close()
def test_server_wires_extension_control_subscription_and_health() -> None:
root = Path(__file__).resolve().parents[2]
server = (root / "src" / "cognitive_agent" / "server.py").read_text(
encoding="utf-8"
)
seat_service = (
root / "src" / "cognitive_agent" / "application" / "seat_service.py"
).read_text(encoding="utf-8")
console = (root / "apps" / "web-console" / "console-v2.js").read_text(
encoding="utf-8"
)
assert "extension_control = ExtensionControlConsumer(" in server
assert "executor=extension.execute_bound_infrared_action" in server
assert "service.set_extension_control_handler(extension_control.submit)" in server
assert "extension_control.reject_transport_message" in server
assert '"extension_control": self.extension_control.snapshot()' in server
assert 'path == "/api/extension/control/status"' in server
assert "self.extension_control.wait_for_snapshot(" in server
assert "SeatDemoHandler.extension_control.start()" in server
assert "SeatDemoHandler.extension_control.close()" in server
assert "def set_extension_control_handler" in seat_service
assert "def set_extension_control_rejection_handler" in seat_service
assert 'fetch(`/api/extension/control/status?${query}`' in console
assert "function applyExtensionControlSnapshot" in console
assert "if (revision < extensionControlSync.revision)" in console
assert 'vehicle_mqtt_extension: "车端 MQTT"' in console
assert 'delivery_transport: "车端 MQTT → 红外 TCP"' in console
assert 'semantic_instruction: `${extensionName} · ${actionName}`' in console
assert (
'category: `${extensionName} · target_scope=${targetScope} · at_ms=${atMs}`'
in console
)
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
import time
from collections import Counter
from typing import Any
from cognitive_agent.application.extension_toy_publisher import (
NfcExtensionToyPublisher,
build_extension_toy_messages,
normalize_extension_nfc_id,
)
def wait_until(predicate, timeout: float = 1.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.005)
return bool(predicate())
def capability(
capability_id: str,
*,
device_id: str = "toy-light-001",
device_name: str = "闪烁灯",
card_uid: str = "E113B203",
slot: int = 2,
action: str = "打开",
enabled: bool = True,
status: str = "learned",
**extra: Any,
) -> dict[str, Any]:
return {
"id": capability_id,
"status": status,
"enabled": enabled,
"device_id": device_id,
"device_name": device_name,
"card_uid": card_uid,
"slot": slot,
"action": action,
**extra,
}
def test_message_projection_groups_devices_orders_slots_and_filters_catalog() -> None:
catalog = [
capability("light-blue", slot=5, action="切换为蓝色"),
capability("fan-on", device_id="toy-fan-001", device_name="小风扇"),
capability("light-on", slot=2, action="打开"),
capability("duplicate", slot=2, action="打开"),
capability("disabled", slot=1, action="关闭", enabled=False),
capability("other-card", slot=6, action="设置为白色", card_uid="04A1B2C3"),
capability("draft", slot=3, action="切换为红色", status="draft"),
capability("missing-enabled", slot=4, action="切换为绿色", enabled=None),
]
messages = build_extension_toy_messages("e1:13-b2 03", catalog)
assert [message["extension_name"] for message in messages] == ["小风扇", "闪烁灯"]
fan, light = messages
assert fan["actions"] == [
{
"type": "infrared_emit",
"at_ms": 0,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": "toy-fan-001",
"operation": "emit",
"parameters": {"action": "打开", "slot": 2},
}
]
assert [action["parameters"] for action in light["actions"]] == [
{"action": "打开", "slot": 2},
{"action": "切换为蓝色", "slot": 5},
]
assert {action["target_scope"] for action in light["actions"]} == {
"toy-light-001"
}
def test_projection_prefers_chinese_name_but_keeps_english_stable_scope() -> None:
catalog = [
capability(
"desk-light-on",
device_id="desk-light-01",
device_name="Desk Light",
object_names=["闪烁灯"],
)
]
[message] = build_extension_toy_messages("E113B203", catalog)
assert message["extension_name"] == "闪烁灯"
assert message["actions"][0]["target_scope"] == "desk-light-01"
assert normalize_extension_nfc_id("04:a1-b2 c3") == "04A1B2C3"
assert normalize_extension_nfc_id("not-a-card") == ""
def test_each_detection_publishes_three_frames_per_device_and_then_stops() -> None:
nfc: dict[str, Any] = {
"reader": {"online": True},
"card": {"action": "present", "uid": "E113B203"},
}
catalog = [
capability("light-on"),
capability("light-blue", slot=5, action="切换为蓝色"),
capability("fan-on", device_id="toy-fan-001", device_name="小风扇"),
]
publications: list[tuple[float, dict[str, Any]]] = []
def publish(payload: dict[str, Any]) -> dict[str, Any]:
publications.append((time.monotonic(), payload))
return {"delivery_status": "queued", "message_id": len(publications)}
service = NfcExtensionToyPublisher(
nfc_provider=lambda: nfc,
catalog_provider=lambda: catalog,
publisher=publish,
burst_window_s=0.12,
poll_interval_s=0.005,
)
service.start()
try:
assert wait_until(lambda: len(publications) == 6)
assert publications[-1][0] - publications[0][0] <= 0.12
assert Counter(
publication["actions"][0]["target_scope"]
for _at, publication in publications
) == {"toy-fan-001": 3, "toy-light-001": 3}
status = service.snapshot()
assert status["state"] == "burst_complete"
assert status["burst_frame_attempt_count"] == 3
assert status["burst_publish_attempt_count"] == 6
assert status["burst_published_count"] == 6
assert status["qos"] == 1
assert status["retain"] is False
time.sleep(0.14)
assert len(publications) == 6
nfc["card"] = None
assert wait_until(lambda: service.snapshot()["state"] == "waiting_for_nfc")
nfc["card"] = {"action": "present", "uid": "E113B203"}
assert wait_until(lambda: len(publications) == 12)
assert service.snapshot()["state"] == "burst_complete"
finally:
service.close()
def test_no_matching_ability_publishes_nothing() -> None:
nfc = {"card": {"action": "present", "uid": "E113B203"}}
publications: list[dict[str, Any]] = []
service = NfcExtensionToyPublisher(
nfc_provider=lambda: nfc,
catalog_provider=lambda: [
capability("other-card", card_uid="04A1B2C3"),
capability("disabled", enabled=False),
],
publisher=lambda payload: publications.append(dict(payload)) or {},
burst_window_s=0.03,
poll_interval_s=0.005,
)
service.start()
try:
assert wait_until(
lambda: service.snapshot()["state"] == "no_matching_extension"
)
time.sleep(0.05)
assert publications == []
finally:
service.close()
def test_failed_burst_attempts_only_three_frames_until_redetected() -> None:
nfc: dict[str, Any] = {"card": {"action": "present", "uid": "E113B203"}}
attempts: list[float] = []
def fail_publish(_payload: dict[str, Any]) -> dict[str, Any]:
attempts.append(time.monotonic())
raise RuntimeError("broker unavailable")
service = NfcExtensionToyPublisher(
nfc_provider=lambda: nfc,
catalog_provider=lambda: [capability("light-on")],
publisher=fail_publish,
burst_window_s=0.06,
poll_interval_s=0.005,
)
service.start()
try:
assert wait_until(lambda: len(attempts) == 3)
status = service.snapshot()
assert status["state"] == "burst_failed"
assert status["burst_publish_attempt_count"] == 3
assert status["burst_published_count"] == 0
assert status["last_error"] == "broker unavailable"
time.sleep(0.08)
assert len(attempts) == 3
finally:
service.close()
def test_server_wires_publisher_without_invoking_infrared_execution() -> None:
from pathlib import Path
root = Path(__file__).resolve().parents[2]
server = (root / "src" / "cognitive_agent" / "server.py").read_text(
encoding="utf-8"
)
service = (
root / "src" / "cognitive_agent" / "application" / "seat_service.py"
).read_text(encoding="utf-8")
assert "extension_toys = NfcExtensionToyPublisher(" in server
assert "catalog_provider=extension.list_peripheral_capabilities" in server
assert "publisher=service.publish_extension_toy_message" in server
assert "SeatDemoHandler.extension_toys.start()" in server
assert "SeatDemoHandler.extension_toys.close()" in server
assert "publish_extension_toy_message" in service
method = service.split("def publish_extension_toy_message", 1)[1].split(
"\n def ", 1
)[0]
assert "_vehicle_mqtt_transport.publish_extension_toy_message" in method
assert "execute_gateway" not in method
+54 -11
View File
@@ -6,7 +6,10 @@ WEB = ROOT / "apps" / "web-console"
def source(name: str) -> str:
return (WEB / name).read_text(encoding="utf-8")
content = (WEB / name).read_text(encoding="utf-8")
if name == "index.html":
content += "\n" + (WEB / "src" / "App.vue").read_text(encoding="utf-8")
return content
def test_shadcn_semantic_theme_tokens_drive_the_console() -> None:
@@ -36,19 +39,20 @@ def test_shadcn_semantic_theme_tokens_drive_the_console() -> None:
def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
html = source("index.html")
main = source("src/main.js")
service_worker = source("service-worker.js")
for asset in (
"/styles.css?v=dual-seat-23",
"/console-v2.css?v=delivery-state-69",
"/self-extension.css?v=pet-settings-52",
"/speech-input.css?v=sensevoice-7",
"/codepen-glass-theme.css?v=creative-cloud-2",
for source_asset, cached_asset in (
('import "../styles.css"', "/styles.css?v=dual-seat-23"),
('import "../console-v2.css"', "/console-v2.css?v=delivery-state-69"),
('import "../self-extension.css"', "/self-extension.css?v=pet-settings-52"),
('import "../speech-input.css"', "/speech-input.css?v=sensevoice-7"),
('import "../codepen-glass-theme.css"', "/codepen-glass-theme.css?v=creative-cloud-5"),
):
assert asset in html
assert f'"{asset}"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v305"' in service_worker
assert source_asset in main
assert f'"{cached_asset}"' in service_worker
assert 'import "./styles/design-system.css"' in main
assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
@@ -91,6 +95,45 @@ def test_reference_glass_theme_covers_the_complete_workspace() -> None:
assert "border-radius: 0 !important" in theme
def test_reference_theme_uses_one_transform_only_floating_card_contract() -> None:
theme = source("codepen-glass-theme.css")
for contract in (
"--glass-card-radius:",
"--glass-card-lift:",
"--glass-card-rest-shadow:",
"--glass-card-float-shadow:",
".assistant-dispatched-controls",
".assistant-mode-draft",
".mode-gallery-card",
".mode-editor-command-card",
".v2-device-card",
".v2-pet-card",
".energy-kpi",
"transform: translateY(var(--glass-card-lift))",
"@media (prefers-reduced-motion: reduce)",
"transition: none !important",
):
assert contract in theme
def test_dark_theme_uses_one_glass_scrim_for_every_modal_backdrop() -> None:
theme = source("codepen-glass-theme.css")
for contract in (
"--glass-dialog-scrim:",
"--glass-dialog-scrim-filter:",
".mode-gallery-backdrop",
".mode-editor-backdrop",
".energy-monitor-layer",
".device-power-backdrop",
".v2-extension-dialog::backdrop",
"background: var(--glass-dialog-scrim) !important",
"backdrop-filter: var(--glass-dialog-scrim-filter) !important",
):
assert contract in theme
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
styles = source("styles.css")
console = source("console-v2.css")
+12 -6
View File
@@ -146,7 +146,7 @@ console.log(JSON.stringify(context.result));
def test_mode_gallery_layout_and_copy_expose_three_management_sections() -> None:
app = (WEB / "app.js").read_text(encoding="utf-8")
html = (WEB / "index.html").read_text(encoding="utf-8")
template = (WEB / "src" / "App.vue").read_text(encoding="utf-8")
styles = (WEB / "styles.css").read_text(encoding="utf-8")
definitions = app[
app.index("const MODE_GALLERY_GROUPS")
@@ -161,8 +161,8 @@ def test_mode_gallery_layout_and_copy_expose_three_management_sections() -> None
assert 'if (group.id === "custom") grid.append(createModeGalleryAddCard())' in app
assert "function createModeGalleryEmpty(group)" in app
assert 'section.setAttribute("aria-labelledby", headingId)' in app
assert "自定义模式 · 静态模式 · 动态模式" in html
assert "按来源与运行方式整理,快速定位并管理模式" in html
assert "自定义模式 · 静态模式 · 动态模式" in template
assert "按来源与运行方式整理,快速定位并管理模式" in template
assert "grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));" in styles
@@ -173,11 +173,17 @@ def test_top_mode_cards_render_real_actions_in_a_body_level_hover_and_focus_prev
assert "function modeExecutionActionViews(mode)" in app
assert "Array.isArray(mode?.operations)" in app
assert "function renderModeActionPopover(mode)" in app
assert "modeEditorReadableCommands(mode?.command_flow)" in app
assert 'eyebrow.dataset.modeActionEyebrow = "true"' in app
assert '"最新控制指令流"' in app
assert 'popover.setAttribute("role", "tooltip")' in app
assert "document.body.append(popover)" in app
assert 'device.innerHTML = "<b>设备</b>"' in app
assert 'target.innerHTML = "<b>目标</b>"' in app
assert 'parameters.innerHTML = "<b>参数</b>"' in app
assert 'appendMetadata("语义", action.semantic || action.operation_ref)' in app
assert 'appendMetadata("实际", action.command)' in app
assert 'appendMetadata("通道", action.channel)' in app
assert 'appendMetadata("设备", action.device)' in app
assert 'appendMetadata("目标", action.target)' in app
assert 'appendMetadata("参数", action.parameters)' in app
assert 'button.setAttribute("aria-describedby", popover.id)' in app
assert 'group.addEventListener("pointerover"' in app
assert 'group.addEventListener("pointerout"' in app
+247
View File
@@ -1,11 +1,14 @@
from __future__ import annotations
import json
import threading
import time
from pathlib import Path
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.mode_scene_catalog import (
ModeSceneCatalogPublisher,
ModeSceneMqttBurstPublisher,
build_mode_scene_catalog,
)
from cognitive_agent.application.seat_service import REPOSITORY_ROOT
@@ -156,3 +159,247 @@ def test_repository_presets_are_exported_without_internal_command_flows() -> Non
encoded = json.dumps(catalog, ensure_ascii=False)
assert "command_flow" not in encoded
assert "source_request_id" not in encoded
def test_saved_editor_flow_projects_all_native_and_extension_operations(
tmp_path: Path,
) -> None:
registry = ModeProfileRegistry(
tmp_path / "presets",
user_root=tmp_path / "profiles",
)
operations = [
{
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "driver",
"parameters": {"temperature_c": 24},
},
{
"domain": "interior_light",
"capability": "interior_light.set_reading_power",
"target_scope": "all",
"parameters": {"state": "on"},
},
{
"domain": "roof",
"capability": "roof.set_shade_motion",
"target_scope": "vehicle",
"parameters": {"motion": "open"},
},
{
"domain": "window",
"capability": "window.set_position",
"target_scope": "front_right",
"parameters": {"position_pct": 50},
},
]
def configured_operation(
operation_id: str,
capability: str,
zone: str,
parameters: dict[str, object],
*,
device: str,
) -> dict[str, object]:
return {
"id": operation_id,
"capability": capability,
"parameters": parameters,
"target": {"device": device, "zone": zone},
}
function_configs = {
"native_climate": {
"spec": {
"domain": "climate",
"operations": [
configured_operation(
"driver_set_temperature_1",
"climate.set_temperature",
"driver",
{"temperature_c": 24},
device="hvac",
)
],
}
},
"native_interior_light": {
"spec": {
"domain": "interior_light",
"operations": [
configured_operation(
"all_set_reading_power_1",
"interior_light.set_reading_power",
"all",
{"state": "on"},
device="reading_light",
)
],
}
},
"native_roof": {
"spec": {
"domain": "roof",
"operations": [
configured_operation(
"vehicle_set_shade_motion_1",
"roof.set_shade_motion",
"vehicle",
{"motion": "open"},
device="roof_shade",
)
],
}
},
"native_window": {
"spec": {
"domain": "window",
"operations": [
configured_operation(
"front_right_set_position_1",
"window.set_position",
"front_right",
{"position_pct": 50},
device="window",
)
],
}
},
"self_extension": {
"spec": {
"domain": "self_extension",
"operations": [
{
"id": "ir-slot:toy-light-001:2-1",
"capability": "infrared.emit",
"parameters": {"action": "打开", "slot": 2},
"target": {
"card_uid": "2CC8B103",
"device": "toy-light-001",
"name": "闪烁灯",
},
},
{
"id": "ir-slot:toy-light-001:6-2",
"capability": "infrared.emit",
"parameters": {"action": "设置为白色", "slot": 6},
"target": {
"card_uid": "2CC8B103",
"device": "toy-light-001",
"name": "闪烁灯",
},
},
],
}
},
}
registry.save_mode(
"阅读模式",
operations,
mode_id="mode-reading",
vehicle_id="denza_n7",
command_flow={"function_configs": function_configs},
)
catalog = build_mode_scene_catalog(
registry,
"denza_n7",
generated_at="2026-09-15T00:00:00Z",
)
reading = next(
scene for scene in catalog["scenes"] if scene["id"] == "mode-reading"
)
assert reading["operation_count"] == 6
assert len(reading["actions"]) == 6
assert [action["capability"] for action in reading["actions"]] == [
"climate.set_temperature",
"interior_light.set_reading_power",
"roof.set_shade_motion",
"window.set_position",
"infrared.emit",
"infrared.emit",
]
assert reading["domains"] == [
"climate",
"interior_light",
"roof",
"self_extension",
"window",
]
first_infrared, second_infrared = reading["actions"][-2:]
assert first_infrared["parameters"] == {"action": "打开", "slot": 2}
assert second_infrared["parameters"] == {"action": "设置为白色", "slot": 6}
assert first_infrared["target"] == {
"card_uid": "2CC8B103",
"device": "toy-light-001",
"name": "闪烁灯",
}
assert second_infrared["target"] == first_infrared["target"]
assert first_infrared["operation"] == "emit"
def test_mode_scene_mqtt_burst_publishes_only_one_exact_card_three_times() -> None:
publications: list[tuple[float, dict[str, object]]] = []
complete = threading.Event()
scene: dict[str, object] = {
"id": "mode-reading",
"label": "阅读模式",
"name": "阅读模式",
"background_time": "noon",
"description": "由自然语言生成的自定义模式。",
"icon": "bookmark-plus",
"aliases": ["阅读模式", "阅读"],
"origin": "user",
"deletable": True,
"behavior": "static",
"domains": ["climate"],
"operation_count": 1,
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
"profile_revision": "1234567890abcdef",
"actions": [
{
"type": "climate_temperature",
"at_ms": 0,
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "passenger",
"operation": "set",
"parameters": {"temperature_c": 24},
"zone": "passenger",
"celsius": 24,
}
],
}
def publish(payload: dict[str, object]) -> dict[str, object]:
publications.append((time.monotonic(), payload))
if len(publications) == 3:
complete.set()
return {"delivery_status": "published", "message_id": len(publications)}
publisher = ModeSceneMqttBurstPublisher(
publish,
topic="from_pc/scenes",
burst_window_s=0.12,
)
queued_at = time.monotonic()
try:
queued = publisher.queue(scene)
assert complete.wait(timeout=1.0)
assert queued == {
"state": "queued",
"topic": "from_pc/scenes",
"scene_id": "mode-reading",
"burst_count": 3,
"burst_window_ms": 120,
}
assert len(publications) == 3
assert publications[-1][0] - queued_at < 0.2
assert all(payload == scene for _, payload in publications)
assert all("scenes" not in payload for _, payload in publications)
assert publisher.snapshot()["state"] == "burst_complete"
finally:
publisher.close()
+187 -2
View File
@@ -17,8 +17,13 @@ from cognitive_agent.infrastructure.can.hardware import (
)
from cognitive_agent.infrastructure.can.mqtt_transport import (
MQTT_CAN_SCHEMA,
MQTT_EXTENSION_CONTROL_TOPIC,
MQTT_EXTENSION_TOY_TOPIC,
MQTT_MODE_SCENE_TOPIC,
MQTT_PET_TOPIC,
MqttCanTransport,
mqtt_extension_toy_payload,
mqtt_mode_scene_payload,
mqtt_pet_payload,
parse_mqtt_can_payload,
)
@@ -80,10 +85,19 @@ class _FakeMqttClient:
self.published.append((topic, payload, qos, retain))
return _PublishResult()
def emit(self, topic: str, payload: object) -> None:
def emit(
self,
topic: str,
payload: object,
*,
retain: bool = False,
dup: bool = False,
) -> None:
message = SimpleNamespace(
topic=topic,
payload=json.dumps(payload).encode("utf-8"),
retain=retain,
dup=dup,
)
self.on_message(self, None, message)
@@ -557,6 +571,174 @@ class MqttCanTransportTests(unittest.TestCase):
{"pet_name": "团团", "nfc_id": "not-a-uid", "action": "摇尾巴"}
)
def test_extension_toy_uses_fixed_topic_qos_one_and_never_retains(self) -> None:
# This application-level topic is contractually QoS 1 even if the
# vehicle CAN relay itself is configured with a different QoS.
self.transport.settings.qos = 0
payload = {
"extension_name": "闪烁灯",
"actions": [
{
"type": "infrared_emit",
"at_ms": 0,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": "toy-light-001",
"operation": "emit",
"parameters": {"action": "打开", "slot": 2},
}
],
}
publication = self.transport.publish_extension_toy_message(payload)
topic, raw_payload, qos, retain = self.client.published[-1]
self.assertEqual(topic, MQTT_EXTENSION_TOY_TOPIC)
self.assertEqual(raw_payload, mqtt_extension_toy_payload(payload))
self.assertEqual(json.loads(raw_payload), payload)
self.assertEqual(qos, 1)
self.assertFalse(retain)
self.assertEqual(publication["qos"], 1)
self.assertEqual(publication["delivery_status"], "published")
def test_extension_toy_rejects_non_english_scope_and_extra_fields(self) -> None:
action = {
"type": "infrared_emit",
"at_ms": 0,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": "闪烁灯",
"operation": "emit",
"parameters": {"action": "打开", "slot": 2},
}
with self.assertRaisesRegex(Exception, "English stable device id"):
self.transport.publish_extension_toy_message(
{"extension_name": "闪烁灯", "actions": [action]}
)
with self.assertRaisesRegex(Exception, "requires exactly"):
self.transport.publish_extension_toy_message(
{
"extension_name": "闪烁灯",
"actions": [{**action, "target_scope": "toy-light-001"}],
"execute": True,
}
)
def test_extension_control_is_dispatched_as_raw_bytes_without_blocking(self) -> None:
received: list[bytes] = []
self.transport.set_extension_control_handler(received.append)
payload = {
"domain": "self_extension",
"operation_count": 1,
"actions": [
{
"type": "infrared_emit",
"at_ms": 0,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": "toy-light-001",
"operation": "emit",
"parameters": {"action": "打开", "slot": 2},
}
],
}
self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload)
self.assertEqual(len(received), 1)
self.assertEqual(json.loads(received[0]), payload)
status = self.transport.public_info()["extension_control"]
self.assertTrue(status["handler_configured"])
self.assertEqual(status["received_count"], 1)
self.assertEqual(status["dispatched_count"], 1)
self.assertEqual(status["rejected_count"], 0)
def test_extension_control_rejects_retained_and_duplicate_deliveries(self) -> None:
received: list[bytes] = []
rejections: list[tuple[str, str]] = []
self.transport.set_extension_control_handler(received.append)
self.transport.set_extension_control_rejection_handler(
lambda error, kind: rejections.append((error, kind))
)
payload = {
"domain": "self_extension",
"operation_count": 1,
"actions": [],
}
self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload, retain=True)
self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload, dup=True)
self.assertEqual(received, [])
status = self.transport.public_info()["extension_control"]
self.assertEqual(status["received_count"], 2)
self.assertEqual(status["dispatched_count"], 0)
self.assertEqual(status["rejected_count"], 2)
self.assertEqual(status["retained_count"], 1)
self.assertEqual(status["duplicate_count"], 1)
self.assertTrue(status["rejection_handler_configured"])
self.assertEqual(
rejections,
[
("retained extension/ctl messages are not executable", "retained"),
("duplicate extension/ctl delivery was ignored", "duplicate"),
],
)
def test_mode_scene_uses_fixed_topic_exact_catalog_card_and_never_retains(
self,
) -> None:
scene = {
"id": "mode-reading",
"label": "阅读模式",
"name": "阅读模式",
"background_time": "noon",
"description": "由自然语言生成的自定义模式。",
"icon": "bookmark-plus",
"aliases": ["阅读模式", "阅读"],
"origin": "user",
"deletable": True,
"behavior": "static",
"domains": ["climate"],
"operation_count": 1,
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
"profile_revision": "1234567890abcdef",
"actions": [
{
"type": "climate_temperature",
"at_ms": 0,
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "passenger",
"operation": "set",
"parameters": {"temperature_c": 24},
"zone": "passenger",
"celsius": 24,
}
],
}
publication = self.transport.publish_mode_scene(scene)
topic, raw_payload, qos, retain = self.client.published[-1]
self.assertEqual(topic, MQTT_MODE_SCENE_TOPIC)
self.assertEqual(raw_payload, mqtt_mode_scene_payload(scene))
self.assertEqual(json.loads(raw_payload), scene)
self.assertNotIn("scenes", json.loads(raw_payload))
self.assertEqual(qos, 1)
self.assertFalse(retain)
self.assertEqual(publication["delivery_status"], "published")
def test_mode_scene_rejects_catalog_wrapper(self) -> None:
with self.assertRaisesRegex(Exception, "one scene only"):
self.transport.publish_mode_scene(
{
"service": "n7-scenes",
"scene_count": 1,
"scenes": [{"id": "mode-reading"}],
}
)
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
self.client.emit(
"n7/n7-headunit/can/state",
@@ -590,7 +772,10 @@ class MqttCanTransportTests(unittest.TestCase):
)
self.assertEqual(
self.client.subscriptions,
[("n7/n7-headunit/can/state", 1)],
[
("n7/n7-headunit/can/state", 1),
(MQTT_EXTENSION_CONTROL_TOPIC, 1),
],
)
def test_first_partial_live_vehicle_snapshot_does_not_wait_for_missing_ids(self) -> None:
+3 -2
View File
@@ -14,6 +14,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
script = source("self-extension.js")
styles = source("self-extension.css")
html = source("index.html")
vite_entry = source("src/main.js")
assert 'data-open-nfc-binding' in script
assert '靠近读卡器后' in script
@@ -61,7 +62,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
assert '.v2-nfc-command-option' in styles
assert '.v2-nfc-select-all' in styles
assert 'self-extension.js?v=pet-settings-52' in html
assert 'self-extension.css?v=pet-settings-52' in html
assert 'import "../self-extension.css";' in vite_entry
def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
@@ -208,5 +209,5 @@ def test_main_parsed_control_panel_renders_validated_infrared_operations() -> No
assert "下发控制指令" in console
assert "已解析控制指令" not in console
assert 'li[data-can-channel="IR"]' in styles
assert "console-v2.js?v=local-first-70" in html
assert "console-v2.js?v=local-first-76" in html
assert 'speech_semantic: "语音语义"' in console
@@ -10,7 +10,11 @@ ROOT = Path(__file__).resolve().parents[2]
def source(name: str) -> str:
return (ROOT / "apps" / "web-console" / name).read_text(encoding="utf-8")
web_root = ROOT / "apps" / "web-console"
content = (web_root / name).read_text(encoding="utf-8")
if name == "index.html":
content += "\n" + (web_root / "src" / "App.vue").read_text(encoding="utf-8")
return content
def test_frontend_only_exposes_user_orchestration_and_extension() -> None:
@@ -827,8 +831,8 @@ def test_parsed_command_delivery_is_independent_of_transport_and_target() -> Non
assert [commands[0]["deliveryLabel"] for commands in parsed] == [
"未下发",
"未下发",
"下发",
"下发",
"已下发 · 待状态确认",
"已下发 · 待状态确认",
"安全阻止",
]
assert [commands[0]["deliveryState"] for commands in parsed] == [
@@ -848,6 +852,137 @@ def test_parsed_command_delivery_is_independent_of_transport_and_target() -> Non
]
def test_delivery_status_distinguishes_blocked_failed_aborted_and_verified() -> None:
node = shutil.which("node")
if node is None:
return
console = source("console-v2.js")
assert 'class="v2-can-command-delivery-detail"' in console
assert "<b>状态说明</b>" in console
start = console.index(" const normalizedCanTarget")
end = console.index("\n\n function renderParsedCanCommands", start)
helpers = console[start:end]
def sample(
operation: dict,
*,
status: str,
message: str = "",
telemetry: dict | None = None,
) -> dict:
return {
"execution_mode": "hardware",
"control_transport": "wireless_mqtt",
"function_configs": {
"sample": {"spec": {"operations": [operation]}}
},
"execution_report": {
"spec": {
"results": [
{
"ref": operation["id"],
"status": status,
"message": message,
"telemetry": telemetry or {},
}
]
}
},
}
samples = [
sample(
{
"id": "window-blocked",
"capability": "window.set_position",
"target": {"zone": "front-right"},
"parameters": {"position_pct": 20},
},
status="failed",
message="未收到有效且新鲜的 0x388 车窗位置,拒绝启动机构",
telemetry={"verification_status": "failed_or_blocked"},
),
sample(
{
"id": "infrared-dispatch-failed",
"capability": "infrared.emit",
"target": {"device": "reading-light", "name": "阅读灯"},
"parameters": {"action": "开启", "slot": 2},
},
status="failed",
message="GSCU1B 尚未连接到本地 TCP 桥接",
telemetry={"delivery_status": "failed"},
),
sample(
{
"id": "infrared-aborted",
"capability": "infrared.emit",
"target": {"device": "reading-light", "name": "阅读灯"},
"parameters": {"action": "切换为暖光", "slot": 5},
},
status="aborted",
message="前序红外指令失败,未执行",
telemetry={"delivery_status": "not_submitted"},
),
sample(
{
"id": "climate-verified",
"capability": "climate.set_power",
"target": {"zone": "vehicle"},
"parameters": {"state": "on"},
},
status="succeeded",
telemetry={
"delivery_status": "delivered",
"physical_effect_confirmed": True,
},
),
sample(
{
"id": "climate-post-submit-failed",
"capability": "climate.set_power",
"target": {"zone": "vehicle"},
"parameters": {"state": "on"},
},
status="failed",
message="网关提交后执行失败",
telemetry={"delivery_status": "submitted"},
),
]
script = (
f"{helpers}\n"
f"const samples = {json.dumps(samples, ensure_ascii=False)};\n"
"process.stdout.write(JSON.stringify(samples.map((sample) => "
"parsedCanCommands(sample)[0])));"
)
completed = subprocess.run(
[node, "-"],
input=script,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
commands = json.loads(completed.stdout)
assert [item["deliveryLabel"] for item in commands] == [
"安全阻止",
"下发失败",
"已中止",
"执行已确认",
"执行失败",
]
assert [item["deliveryState"] for item in commands] == [
"blocked",
"failed",
"aborted",
"submitted",
"failed",
]
assert "0x388 实时位置" in commands[0]["deliveryDetail"]
assert commands[1]["deliveryDetail"] == "GSCU1B 尚未连接到本地 TCP 桥接"
def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() -> None:
app = source("app.js")
console = source("console-v2.js")
@@ -870,6 +1005,12 @@ def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences()
assert 'executionMode: "dry_run"' in app
assert 'clientType: "mode_editor_compile"' in app
assert "previewOnly: true" in app
compile_flow = app.split(
"async function executeModeEditorInstruction", 1
)[1].split("async function completeModeEditor", 1)[0]
assert "renderResult(" not in compile_flow
assert "cognitive-agent:validated-can-commands" not in compile_flow
assert "cognitive-agent:result" not in compile_flow
assert 'const nonDispatchTaskSources = new Set(["mode_editor_compile"])' in console
assert "if (isDispatchTask(task))" in console
assert "下发控制指令" in console
+36 -5
View File
@@ -943,7 +943,7 @@ class SelfExtensionTests(unittest.TestCase):
self.assertFalse(result["physical_state_confirmed"])
self.assertEqual(len(result["device_response"]), 2)
def test_main_orchestration_resolves_only_live_bound_nfc_peripheral(self) -> None:
def test_main_orchestration_uses_durable_binding_after_card_is_removed(self) -> None:
catalog = [
{
"id": "cap-third-party-light-on",
@@ -978,16 +978,25 @@ class SelfExtensionTests(unittest.TestCase):
self.assertIsNone(
resolve_active_peripheral_command("打开氛围灯", catalog, present)
)
self.assertIsNone(
self.assertEqual(
resolve_active_peripheral_command(
"打开第三方氛围灯", catalog, {"card": None}
)
)["id"],
"cap-third-party-light-on",
)
self.assertIsNone(
self.assertEqual(
resolve_active_peripheral_command(
"打开第三方氛围灯",
catalog,
{"card": {"uid": "FFFFFFFF"}},
)["id"],
"cap-third-party-light-on",
)
self.assertIsNone(
resolve_active_peripheral_command(
"打开第三方氛围灯",
[{**catalog[0], "card_uid": ""}],
{"card": None},
)
)
@@ -1059,6 +1068,17 @@ class SelfExtensionTests(unittest.TestCase):
)
self.assertEqual([item["id"] for item in selected], ["on", "red"])
self.assertEqual(
[
item["id"]
for item in active_peripheral_plan_catalog(
"打开第三方氛围灯,红灯闪烁5s后关闭",
catalog,
{"card": None},
)
],
["on", "red"],
)
self.assertEqual(
active_peripheral_plan_catalog(
"打开第三方氛围灯",
@@ -1351,7 +1371,18 @@ class SelfExtensionTests(unittest.TestCase):
)
self.assertEqual(
[item["status"] for item in result["operation_results"]],
["failed"],
["failed", "aborted"],
)
self.assertEqual(
result["operation_results"][1]["message"],
"前序红外指令失败,未执行",
)
self.assertEqual(
result["operation_results"][1]["telemetry"]["delivery_status"],
"not_submitted",
)
self.assertFalse(
result["operation_results"][1]["telemetry"]["transport_submitted"]
)
def test_power_on_then_white_waits_800ms_and_continues_without_ack(self) -> None:
@@ -10,13 +10,18 @@ def source(name: str) -> str:
def test_spatial_ui_contract_is_loaded_last_and_cached() -> None:
html = source("index.html")
main = source("src/main.js")
service_worker = source("service-worker.js")
asset = "/ui-consistency.css?v=spatial-ui-5"
asset = "/ui-consistency.css?v=spatial-ui-8"
assert asset in html
assert 'import "../ui-consistency.css"' in main
assert f'"{asset}"' in service_worker
assert html.index("/energy-monitor.css") < html.index(asset)
assert main.index('import "../energy-monitor.css"') < main.index(
'import "../ui-consistency.css"'
)
assert main.index('import "../ui-consistency.css"') < main.index(
'import "./styles/design-system.css"'
)
def test_same_kind_surfaces_share_geometry_tokens() -> None:
@@ -116,12 +121,16 @@ def test_pet_and_tcp_dialog_controls_keep_accessible_aligned_layout() -> None:
def test_compact_sidebar_overrides_cannot_break_shared_geometry() -> None:
styles = source("ui-consistency.css")
design_system = source("src/styles/design-system.css")
assert ".v2-sync-execution-field," in styles
assert ".v2-can-command-panel" in styles
assert "border-radius: var(--ui-radius-item) !important" in styles
assert ".v2-sync-log {" in styles
assert "border-radius: var(--ui-radius-section) !important" in styles
assert "body.console-v2 .v2-sync-log h3" in design_system
assert "font-size: var(--n7-font-card-title) !important" in design_system
assert "white-space: nowrap" in design_system
def test_assistant_composer_hint_stays_inside_narrow_input() -> None:
@@ -133,9 +142,13 @@ def test_assistant_composer_hint_stays_inside_narrow_input() -> None:
assert "max-width: calc(100% - 132px)" in styles
def test_failed_delivery_uses_one_semantic_status_treatment() -> None:
def test_delivery_failures_use_distinct_semantic_status_treatments() -> None:
styles = source("ui-consistency.css")
assert '[data-delivery-state="blocked"]' in styles
assert '[data-delivery-state="failed"]' in styles
assert '[data-delivery-state="aborted"]' in styles
assert ".v2-can-command-delivery-detail" in styles
assert "color: var(--destructive)" in styles
assert "color: var(--status-warning-foreground)" in styles
assert "color: var(--muted-foreground)" in styles
+37 -15
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
from pathlib import Path
@@ -11,25 +12,44 @@ def source(name: str) -> str:
return (WEB / name).read_text(encoding="utf-8")
def test_vue_presentation_assets_are_local_ordered_and_cached() -> None:
def test_vue_vite_application_is_the_primary_frontend_entry() -> None:
html = source("index.html")
main = source("src/main.js")
app = source("src/App.vue")
package = json.loads(source("package.json"))
vite = source("vite.config.js")
service_worker = source("service-worker.js")
runtime = "/vendor/vue/3.5.21/vue.runtime.global.prod.js"
presentation = "/vue-ui.js?v=vue-presentation-3"
assert html.index(runtime) < html.index(presentation) < html.index(
"/app.js?v=dual-seat-140"
)
assert html.index(presentation) < html.index(
"/assistant-chat.js?v=codex-assistant-33"
)
assert f'"{runtime}"' in service_worker
assert f'"{presentation}"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v305"' in service_worker
assert '<div id="app" aria-busy="true"></div>' in html
assert '<script type="module" src="/src/main.js"></script>' in html
assert 'import { createApp, nextTick } from "vue"' in main
assert 'import App from "./App.vue"' in main
assert 'application.mount("#app")' in main
assert 'defineOptions({ name: "N7ConsoleApp" })' in app
assert 'class="workspace"' in app
assert package["dependencies"]["vue"] == "3.5.21"
assert package["scripts"]["build"] == "vite build"
assert 'import vue from "@vitejs/plugin-vue"' in vite
assert 'outDir: outputRoot' in vite
assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker
assert "unpkg.com" not in html
assert "cdn.jsdelivr.net" not in html
def test_python_and_one_click_startup_use_the_vite_distribution() -> None:
server = (ROOT / "src" / "cognitive_agent" / "server.py").read_text(
encoding="utf-8"
)
launcher = (ROOT / "tools" / "start_n7web.py").read_text(encoding="utf-8")
assert 'WEB_CONSOLE_DIST_ROOT = WEB_CONSOLE_SOURCE_ROOT / "dist"' in server
assert 'if (WEB_CONSOLE_DIST_ROOT / "index.html").is_file()' in server
assert 'parser.add_argument(\n "--skip-web-build"' in launcher
assert 'commands.append([str(npm), "ci"])' in launcher
assert 'commands.append([str(npm), "run", "build"])' in launcher
assert "_build_web_console(npm)" in launcher
def test_vendored_vue_runtime_and_license_are_present() -> None:
runtime = WEB / "vendor" / "vue" / "3.5.21" / "vue.runtime.global.prod.js"
license_file = WEB / "vendor" / "vue" / "3.5.21" / "LICENSE"
@@ -66,7 +86,7 @@ def test_vue_islands_render_modes_and_dispatched_controls() -> None:
assert ".assistant-dispatched-controls-vue-host" in styles
def test_existing_dom_renderers_remain_as_no_vue_fallbacks() -> None:
def test_existing_dom_renderers_remain_as_transition_fallbacks() -> None:
app = source("app.js")
assistant = source("assistant-chat.js")
@@ -81,5 +101,7 @@ def test_existing_dom_renderers_remain_as_no_vue_fallbacks() -> None:
'document.createElement("details")', command_vue_call
)
assert command_fallback > command_vue_call
assert "renderDispatchedControls: () => false" in source("vue-ui.js")
assert "renderModeChips: () => false" in source("vue-ui.js")
vue_ui = source("vue-ui.js")
assert 'from "vue"' in vue_ui
assert "renderDispatchedControls: () => false" not in vue_ui
assert "renderModeChips: () => false" not in vue_ui
+50
View File
@@ -7,6 +7,7 @@ import argparse
import json
import os
import shlex
import shutil
import subprocess
import sys
import time
@@ -21,6 +22,7 @@ PYTHON_SOURCE_PATHS = (
REPOSITORY_ROOT / "src",
REPOSITORY_ROOT / "domains" / "seat" / "src",
)
WEB_CONSOLE_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
@dataclass(frozen=True)
@@ -90,6 +92,11 @@ def _parser() -> argparse.ArgumentParser:
action="store_true",
help="print the resolved commands without starting processes",
)
parser.add_argument(
"--skip-web-build",
action="store_true",
help="reuse the existing Vite dist output instead of rebuilding it",
)
return parser
@@ -176,6 +183,37 @@ def _display_command(command: Sequence[str]) -> str:
return shlex.join(command)
def _resolve_npm() -> Path:
executable = shutil.which("npm.cmd" if os.name == "nt" else "npm")
if not executable:
raise ValueError(
"npm is required to build the Vue 3 + Vite web console; "
"install Node.js or pass --skip-web-build to reuse an existing dist"
)
return Path(executable).resolve()
def _web_build_commands(npm: Path) -> list[list[str]]:
commands: list[list[str]] = []
vite_binary = WEB_CONSOLE_ROOT / "node_modules" / ".bin" / (
"vite.cmd" if os.name == "nt" else "vite"
)
if not vite_binary.is_file():
commands.append([str(npm), "ci"])
commands.append([str(npm), "run", "build"])
return commands
def _build_web_console(npm: Path) -> None:
for command in _web_build_commands(npm):
print(f"Web build: {_display_command(command)}", flush=True)
completed = subprocess.run(command, cwd=WEB_CONSOLE_ROOT, check=False)
if completed.returncode != 0:
raise RuntimeError(
f"Vue 3 + Vite web build failed with exit code {completed.returncode}"
)
def _catalog_is_ready(path: Path) -> bool:
try:
document = json.loads(path.read_text(encoding="utf-8"))
@@ -257,6 +295,7 @@ def run(argv: Sequence[str] | None = None) -> int:
try:
python = _resolve_python(args.python)
catalog = _resolve_path(args.catalog)
npm = None if args.skip_web_build else _resolve_npm()
if args.catalog_ready_timeout <= 0:
raise ValueError("catalog-ready-timeout must be greater than zero")
except ValueError as exc:
@@ -269,9 +308,20 @@ def run(argv: Sequence[str] | None = None) -> int:
print(f"Catalog: {catalog}", flush=True)
print(f"Main: {_display_command(main_command)}", flush=True)
print(f"Cards: {_display_command(scene_command)}", flush=True)
if npm is not None:
for command in _web_build_commands(npm):
print(f"Web build: {_display_command(command)}", flush=True)
if args.dry_run:
return 0
if npm is not None:
try:
_build_web_console(npm)
except RuntimeError as exc:
print(f"startup failed: {exc}", file=sys.stderr, flush=True)
return 1
children: list[ChildProcess] = []
interrupted = False
try: