feat(n7web): enhance control workflows and status feedback
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+1394
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user