diff --git a/.gitignore b/.gitignore index 1e0b874..304a00e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ *.py[cod] artifacts/runs/ +artifacts/mode-profiles/ artifacts/server/ artifacts/tmp/ artifacts/self-repair/ diff --git a/apps/web-console/app.js b/apps/web-console/app.js index ea31ef8..72e5c71 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -44,10 +44,13 @@ function loadControlTransport() { return ["wired_can", "wireless_mqtt"].includes(stored) ? stored : "wired_can"; } +const DEFAULT_EXECUTION_MODE = "hardware"; + const state = { provider: "openai", reasoningEffort: localStorage.getItem("cognitive-agent.reasoning-effort") || "medium", - executionMode: "dry_run", + executionMode: DEFAULT_EXECUTION_MODE, + preferredExecutionMode: DEFAULT_EXECUTION_MODE, controlTransport: loadControlTransport(), conversationId: loadConversationId(), vehicleId: null, @@ -55,7 +58,6 @@ const state = { result: null, vehicleStateSnapshot: null, vehicleStateTurn: -1, - progressTimer: null, seatTwin: null, seatActuatorState: {}, seatFeedbackSource: "simulated", @@ -72,6 +74,7 @@ const state = { modeRunTimer: null, modeDraftCommand: "", lastSuccessfulUtterance: "", + modeEditor: null, }; const $ = (selector, root = document) => root.querySelector(selector); @@ -84,6 +87,7 @@ const apiRequest = (input, options) => window.CognitiveAgentNetwork.request(inpu const HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000; const REQUEST_TIMEOUT_MS = 45 * 1000; const DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS = 5000; +const AUTHORITATIVE_CH1_SOURCES = new Set(["physical_can_ch1", "wireless_mqtt_ch1"]); function requestTimeoutForExecutionMode(mode) { return mode === "hardware" ? HARDWARE_REQUEST_TIMEOUT_MS : REQUEST_TIMEOUT_MS; @@ -95,6 +99,66 @@ function physicalStatePollIntervalMs() { return Math.max(250, Math.min(DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS, configured)); } +function isAuthoritativeCh1Source(value) { + return AUTHORITATIVE_CH1_SOURCES.has(String(value || "")); +} + +function renderCabinLiveStatus(networkStatus = state.connectionStatus) { + const indicator = $("#cabinLiveStatus"); + if (!indicator) return; + const label = $("b", indicator); + if (!label) return; + const summarySystem = $("#cabinSummarySystem"); + const setSummarySystem = (text, status) => { + if (!summarySystem) return; + summarySystem.textContent = text; + summarySystem.dataset.state = status; + }; + if (networkStatus !== "online") { + indicator.dataset.state = networkStatus === "connecting" ? "connecting" : "offline"; + label.textContent = networkStatus === "connecting" + ? "正在连接车辆服务" + : "车辆服务通信离线"; + setSummarySystem(networkStatus === "connecting" ? "连接中" : "服务离线", "waiting"); + return; + } + + const selected = selectedVehicleTransport(); + const wireless = state.controlTransport === "wireless_mqtt"; + const transportLabel = wireless ? "无线 CAN" : "有线 CAN"; + if (selected?.state_configured !== true) { + indicator.dataset.state = "offline"; + label.textContent = `${transportLabel} 未配置`; + setSummarySystem("链路未配置", "waiting"); + return; + } + + const snapshot = state.vehicleStateSnapshot; + const observedIds = Array.isArray(snapshot?.observedFrameIds) + ? snapshot.observedFrameIds + : []; + const maxAgeMs = Math.max(3000, physicalStatePollIntervalMs() * 4); + const responseAgeMs = Date.now() - Number(snapshot?.receivedAt || 0); + if (!snapshot || responseAgeMs > maxAgeMs || observedIds.length === 0) { + indicator.dataset.state = "connecting"; + label.textContent = `${transportLabel} · 等待 CAN1 状态`; + indicator.title = snapshot?.error || "尚未收到可按矩阵解析的 CAN1 状态帧"; + setSummarySystem("等待状态", "waiting"); + return; + } + + const sourceLabel = snapshot.source === "wireless_mqtt_ch1" ? "无线 CAN" : "有线 CAN"; + indicator.dataset.state = "online"; + label.textContent = `${sourceLabel} 实时 · ${observedIds.length} 类报文`; + indicator.title = [ + snapshot.capturedAt ? `采集时间 ${snapshot.capturedAt}` : "", + observedIds.length ? `报文 ${observedIds.join("、")}` : "", + ].filter(Boolean).join(" · "); + if (summarySystem?.dataset.state !== "warning") { + setSummarySystem("CAN 实时", "healthy"); + } +} + function refreshIcons() { if (window.lucide) { window.lucide.createIcons({ attrs: { "aria-hidden": "true" } }); @@ -169,7 +233,7 @@ function initJointTwinLayout() { const cabinHeadingActions = document.createElement("div"); cabinHeadingActions.className = "cabin-heading-actions"; cabinHeadingActions.innerHTML = ` - 通信正常 · 7 个设备在线 + 正在读取车辆 CAN 状态 `; @@ -182,7 +246,7 @@ function initJointTwinLayout() {
当前档位
--
实时车速
-- km/h
门锁状态
状态未知
-
系统状态
无故障
+
系统状态
等待状态
-
扩展链路GSCU · 等待连接
RUNTIME / SYNC

运行日志 / 同步记录

@@ -1151,7 +1232,39 @@
`); if (sidebar && sidebarFooter) { sidebar.insertBefore(syncSidebar, sidebarFooter); - $(".v2-sync-sidebar-overview", syncSidebar)?.append(sidebarFooter); + const syncOverview = $(".v2-sync-sidebar-overview", syncSidebar); + const transportField = $("#vehicleTransportField"); + const executionField = $(".field-group:has(.execution-mode-options)"); + const simulationFaultField = $("#simulationFaultField"); + if (syncOverview) { + const linkStack = $(".v2-sync-link-stack", syncOverview); + const insertVisibleControl = (field, className) => { + if (!field) return; + field.classList.add(className); + if (linkStack) syncOverview.insertBefore(field, linkStack); + else syncOverview.append(field); + }; + insertVisibleControl(transportField, "v2-sync-transport-field"); + if (executionField) executionField.hidden = false; + insertVisibleControl(executionField, "v2-sync-execution-field"); + insertVisibleControl(simulationFaultField, "v2-sync-simulation-fault-field"); + const canCommandPanel = make("details", "v2-can-command-panel", ` + + 已解析 CAN 控制指令 + 0 + + +
+

等待自然语言解析结果

+
`); + canCommandPanel.id = "v2CanCommandPanel"; + if (linkStack) syncOverview.insertBefore(canCommandPanel, linkStack); + else syncOverview.append(canCommandPanel); + canCommandPanel.addEventListener("toggle", () => { + requestAnimationFrame(syncSidebarLogBaseline); + }); + } + syncOverview?.append(sidebarFooter); const sidebarToggle = $("#v2SidebarToggle", sidebar); if (sidebarToggle) $(".v2-sync-sidebar-heading", syncSidebar)?.append(sidebarToggle); } @@ -1234,8 +1347,12 @@ if (event.target.matches("select[data-device-control]")) storeDeviceControlDraft(event.target); }); $("#v2LogFilter")?.addEventListener("change", renderSyncLogs); + $("#requestForm")?.addEventListener("submit", () => { + renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" }); + }); window.addEventListener("cognitive-agent:result", (event) => { const result = event.detail || {}; + renderParsedCanCommands(result); appendSyncLog({ key: result.request_id ? `result:${result.request_id}` : "", category: "task", @@ -1259,6 +1376,7 @@ renderSyncedDevices(); } if (event.detail?.error) { + renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" }); appendSyncLog({ category: "task", title: "编排任务执行失败", detail: event.detail.error.message || "请求失败", status: "error" }); } }); @@ -1376,23 +1494,18 @@ if (requestStatus && statusTarget && requestStatus.parentElement !== statusTarget) { statusTarget.append(requestStatus); } - $$('[data-v2-control]').forEach((button) => { - const active = button.dataset.v2Control === ui.controlMode; - button.classList.toggle("active", active); - button.setAttribute("aria-pressed", String(active)); - }); requestAnimationFrame(updateModeEdgeHints); const drawer = $("#gestureDrawer"); if (!triggerGesture) { const gestureVisible = ui.controlMode === "gesture"; drawer?.setAttribute("aria-hidden", String(!gestureVisible)); - $("#gestureToggleButton")?.setAttribute("aria-expanded", String(gestureVisible)); + drawer?.classList.toggle("open", gestureVisible); return; } if (ui.controlMode === "gesture") { - if (!drawer?.classList.contains("open")) $("#gestureToggleButton")?.click(); + window.CognitiveAgentGesture?.open?.(); } else if (drawer?.classList.contains("open")) { - $("#gestureCloseButton")?.click(); + window.CognitiveAgentGesture?.close?.(); } } @@ -1441,120 +1554,53 @@ window.setTimeout(() => window.dispatchEvent(new Event("resize")), 180); } - function requestUserSafeExecutionMode() { - const dryRunButton = $('[data-execution-mode="dry_run"]'); - const note = $("#v2ExecutionNote"); - const userTarget = $(".v2-user-target-status"); - const userTargetText = $("#v2UserTargetText"); + function keepRunButtonAvailable() { const runButton = $("#runButton"); - if (!dryRunButton) return; - if (!dryRunButton.classList.contains("active")) dryRunButton.click(); - if (note) note.textContent = "user 固定使用虚拟 CAN 安全执行环境"; - if (userTarget) userTarget.dataset.state = "ready"; - if (userTargetText) userTargetText.textContent = "执行目标:虚拟 CAN"; if (runButton && !runButton.classList.contains("loading")) runButton.disabled = false; - syncExecutionSelector(); } - function syncExecutionSelector() { - const select = $("#v2ExecutionSelect"); - if (!select) return; - const active = $('[data-execution-mode].active'); - if (active) select.value = active.dataset.executionMode; - ["dry_run", "simulated_hardware", "hardware"].forEach((value) => { - const option = $(`option[value="${value}"]`, select); - const button = $(`[data-execution-mode="${value}"]`); - if (option && button) option.disabled = button.disabled; - }); - dropdownControls.get(select)?.sync(); - } - - function syncRuntimePresentation() { - const modelBadge = $("#modelBadge"); - const connectionBadge = $("#connectionBadge"); - const modelValue = $("#v2ModelValue"); - const modelNote = $("#v2ModelNote"); + function syncRuntimePresentation(connectionState = window.CognitiveAgentNetwork?.getState?.()) { const connectionText = $("#v2ConnectionText"); - if (modelBadge && modelValue) { - const text = modelBadge.textContent.trim() || "模型检测中"; - modelValue.textContent = text; - const unavailable = modelBadge.classList.contains("danger"); - $("#v2ModelField")?.classList.toggle("unavailable", unavailable); - $("#v2ModelState").textContent = unavailable ? "LOCAL" : "READY"; - if (modelNote) modelNote.textContent = unavailable - ? "GPT 不可用,自动使用本地规则" - : "服务端安全托管 · 失败自动降级"; - } - if (connectionBadge && connectionText) { - connectionText.textContent = connectionBadge.textContent.trim() || "正在连接服务"; - const online = !connectionBadge.classList.contains("danger") - && !connectionBadge.classList.contains("warning"); - $(".v2-sidebar-footer")?.classList.toggle("offline", !online); - } - syncExecutionSelector(); - requestUserSafeExecutionMode(); - } - - function setVehicleLinkState(connected) { - const card = $("#v2VehicleCard"); - const state = $("#v2VehicleState"); - if (!card || !state) return; - card.dataset.state = connected ? "online" : "offline"; - state.textContent = connected ? "READY" : "OFFLINE"; - } - - async function refreshVehicleLinkState() { - const card = $("#v2VehicleCard"); - const state = $("#v2VehicleState"); - if (card) card.dataset.state = "checking"; - if (state) state.textContent = "CHECK"; - try { - const response = await fetch("/api/extension/health", { - credentials: "same-origin", - headers: { Accept: "application/json" }, - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const health = await response.json(); - setVehicleLinkState(Boolean(health.vehicle?.connected)); - } catch (_error) { - setVehicleLinkState(false); + if (connectionText) { + const status = connectionState?.status || "connecting"; + const labels = { + connecting: "正在连接服务", + online: "服务已连接", + degraded: "连接不稳定", + offline: "服务已断开", + }; + connectionText.textContent = labels[status] || labels.connecting; + connectionText.title = connectionState?.lastError || connectionText.textContent; + $(".v2-sidebar-footer")?.classList.toggle( + "offline", + status === "offline" || status === "degraded", + ); } + keepRunButtonAvailable(); } function bindEvents() { - $$('[data-v2-control]').forEach((button) => { - button.addEventListener("click", () => { - setControlMode(button.dataset.v2Control); - }); - }); $$('.v2-module-tab').forEach((button) => { button.addEventListener("click", () => { if (!button.disabled) setModule(button.dataset.module); }); }); - $("#v2ExecutionSelect")?.addEventListener("change", (event) => { - $(`[data-execution-mode="${event.target.value}"]`)?.click(); - syncExecutionSelector(); - }); $$(".v2-page-button").forEach((button) => { button.addEventListener("click", () => setPage(button.dataset.v2PageTarget)); }); - $("#v2LogoutButton")?.addEventListener("click", () => $("#logoutButton")?.click()); - window.addEventListener("cognitive-agent:connection", (event) => { - if (event.detail?.status === "online") refreshVehicleLinkState(); - else if (["offline", "degraded"].includes(event.detail?.status)) setVehicleLinkState(false); - }); + $("#v2LogoutButton")?.addEventListener( + "click", + () => window.CognitiveAgentConsole?.logout?.(), + ); $("#v2SidebarToggle")?.addEventListener( "click", () => setSidebarCollapsed(!ui.sidebarCollapsed), ); document.addEventListener("click", () => { - closeCustomDropdowns(); closeReasoningPicker(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { - closeCustomDropdowns(); closeReasoningPicker(); clearTwinDevicePreview({ force: true }); } @@ -1583,8 +1629,11 @@ .observe(modeChips, { childList: true }); } - const observer = new MutationObserver(syncRuntimePresentation); - [$("#modelBadge"), $("#connectionBadge"), $("#hardwareMode"), $("#simulationMode")] + window.addEventListener("cognitive-agent:connection", (event) => { + syncRuntimePresentation(event.detail); + }); + const observer = new MutationObserver(() => keepRunButtonAvailable()); + [$("#hardwareMode"), $("#simulationMode")] .filter(Boolean) .forEach((node) => observer.observe(node, { attributes: true, @@ -1604,8 +1653,6 @@ document.body.classList.add("console-v2"); const panelHeading = $(".panel-heading", commandPanel); const modeRail = $(".mode-rail", commandPanel); - const exampleGroup = $(".example-group", commandPanel); - const safetyStrip = $("#safetyStrip", commandPanel); const requestHeader = $(".request-header", mainPanel); const gestureDrawer = $("#gestureDrawer"); @@ -1615,18 +1662,11 @@ requestHeader, gestureDrawer, originalForm, - exampleGroup, - safetyStrip, }; Object.values(detached).filter(Boolean).forEach((node) => node.remove()); - const legacyParking = make("div", "v2-legacy-parking v2-legacy-hidden", ""); - [exampleGroup, safetyStrip].filter(Boolean).forEach((node) => legacyParking.append(node)); - document.body.append(legacyParking); - - const configStack = buildSidebar(commandPanel); - buildConfigFields(configStack, originalForm); - enhanceConfiguredDropdowns(); + buildSidebar(commandPanel); + prepareRuntimeControls(originalForm); buildOperation( mainPanel, originalForm, @@ -1636,9 +1676,6 @@ gestureDrawer, ); buildModuleSwitcher(mainPanel); - $(".pipeline", mainPanel)?.classList.add("v2-legacy-hidden"); - $(".topbar")?.classList.add("v2-legacy-hidden"); - $(".gesture-backdrop")?.classList.add("v2-legacy-hidden"); buildOutput(mainPanel); buildSyncControlLayer(); bindEvents(); @@ -1647,18 +1684,18 @@ setSidebarCollapsed(ui.sidebarCollapsed); setPage(0); syncRuntimePresentation(); - refreshVehicleLinkState(); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); requestAnimationFrame(() => { updateModeEdgeHints(); document.body.classList.add("v2-ready"); + window.setTimeout(() => $("#v2BootScreen")?.remove(), 220); }); } window.CognitiveAgentConsoleV2 = { - enhanceSelect, getModule: () => ui.activeModule, getRole: () => "user", + getParsedCanCommands: (result) => parsedCanCommands(result).map((item) => ({ ...item })), setModule, }; diff --git a/apps/web-console/gesture-control.js b/apps/web-console/gesture-control.js index 73d3de3..cbe1946 100755 --- a/apps/web-console/gesture-control.js +++ b/apps/web-console/gesture-control.js @@ -56,6 +56,8 @@ const state = { customizeOpen: false, engine: null, runtimeReady: false, + enabled: true, + disabledReason: "", cameraStatus: "checking", cameraMessage: "正在检测摄像头…", cameraCheckSequence: 0, @@ -114,7 +116,8 @@ function cameraFailureDetails(error) { function syncCameraButtonAvailability() { const button = $("#gestureStartButton"); if (!button || state.stream) return; - button.disabled = !state.runtimeReady + button.disabled = !state.enabled + || !state.runtimeReady || ["checking", "unavailable", "unsupported"].includes(state.cameraStatus); } @@ -693,29 +696,48 @@ function resetMapping() { } function openDrawer() { - $("#gestureDrawer").classList.add("open"); - $("#gestureDrawer").setAttribute("aria-hidden", "false"); - $("#gestureBackdrop").hidden = false; - requestAnimationFrame(() => $("#gestureBackdrop").classList.add("visible")); - $("#gestureToggleButton").setAttribute("aria-expanded", "true"); + if (!state.enabled) { + notify(state.disabledReason || "当前运行模式未启用手势控制"); + return false; + } + const drawer = $("#gestureDrawer"); + if (!drawer) return false; + drawer.classList.add("open"); + drawer.setAttribute("aria-hidden", "false"); document.body.classList.add("gesture-open"); + return true; } function closeDrawer() { stopCamera(); - $("#gestureDrawer").classList.remove("open"); - $("#gestureDrawer").setAttribute("aria-hidden", "true"); - $("#gestureBackdrop").classList.remove("visible"); - window.setTimeout(() => { $("#gestureBackdrop").hidden = true; }, 240); - $("#gestureToggleButton").setAttribute("aria-expanded", "false"); + const drawer = $("#gestureDrawer"); + drawer?.classList.remove("open"); + drawer?.setAttribute("aria-hidden", "true"); document.body.classList.remove("gesture-open"); } +function setEnabled(enabled, { reason = "" } = {}) { + state.enabled = Boolean(enabled); + state.disabledReason = state.enabled ? "" : String(reason || "当前运行模式未启用手势控制"); + if (!state.enabled) closeDrawer(); + syncCameraButtonAvailability(); + const drawer = $("#gestureDrawer"); + if (drawer) { + drawer.dataset.enabled = String(state.enabled); + drawer.title = state.disabledReason; + } +} + +window.CognitiveAgentGesture = { + open: openDrawer, + close: closeDrawer, + isOpen: () => $("#gestureDrawer")?.classList.contains("open") === true, + setEnabled, +}; + document.addEventListener("DOMContentLoaded", async () => { $("#gestureStartButton").disabled = true; - $("#gestureToggleButton")?.addEventListener("click", openDrawer); $("#gestureCloseButton")?.addEventListener("click", closeDrawer); - $("#gestureBackdrop")?.addEventListener("click", closeDrawer); $("#gestureStartButton")?.addEventListener("click", toggleCamera); $("#gestureStopButton")?.addEventListener("click", () => state.engine?.setArmed(false)); $("#gestureCustomizeButton")?.addEventListener("click", toggleCustomize); diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 2c15c85..c2d0429 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -9,44 +9,26 @@ 511工程-智能座舱 - - + + - - - - - + + + + + - -
-
- -
-

Cognitive Agent

-

CABIN ORCHESTRATION / MULTI-DOMAIN

-
-
-
- - 连接中 - - DRY-RUN - 模型检测中 - 硬件检测中 - -
-
- + +
+ + 511工程-智能座舱 + 正在加载车机控制与实时状态 +
@@ -242,20 +183,6 @@
-
-
01意图抽取
- -
02配置生成
- -
03双层校验
- -
04CAN 编译
- -
05安全门禁
- -
06虚拟执行
-
-
@@ -274,27 +201,27 @@
空调CMD 0x1DE · FB 0x404 · CH1
- OFF + UNKNOWN

可控参数电源 · 温度 · 风量/风向

- 主驾24℃ + 主驾--℃ 风向-
- 副驾24℃ + 副驾--℃ 风向-
-
风量
0 档
-
循环
外循环
-
A/C
关闭
-
前除霜
关闭
+
风量
未知
+
循环
未知
+
A/C
未知
+
前除霜
未知
@@ -321,17 +248,17 @@
遮阳帘CMD 0x431 · FB 0x41A · CH1
- READY + UNKNOWN

可控参数开关 / 停止 · 位置

-
遮阳帘
0%
-
动作
停止
+
遮阳帘
未知
+
动作
未知
初始化
未知
-
热保护
正常
+
热保护
未知
@@ -339,7 +266,7 @@
车内灯光INTERIOR / AMBIENT · CH1
- OFF + UNKNOWN

可控参数阅读灯 · 氛围亮度 · 色号

-
氛围灯
关闭
-
室内/阅读灯
关闭
-
氛围亮度
0 / 0
+
氛围灯
未知
+
室内/阅读灯
未知
+
氛围亮度
- / -
氛围色号
- / -
@@ -358,7 +285,7 @@
车窗CMD 0x431 · FB 0x388 · CH1
- READY + UNKNOWN

可控参数四窗独立 · 开/关/停 · 位置

-
左前窗
0%
-
右前窗
0%
-
左后窗
0%
-
右后窗
0%
+
左前窗
未知
+
右前窗
未知
+
左后窗
未知
+
右后窗
未知
-
前大灯FRONT LIGHT · SWITCH
- OFF +
前大灯FB 0x38A · CH1
+ UNKNOWN

可控参数近/远光 · 雾灯 · 转向灯

-
近光灯
关闭
-
远光灯
关闭
-
雾灯
关闭
-
转向灯
关闭
+
近光灯
未知
+
远光灯
未知
+
雾灯
未知
+
转向灯
未知
@@ -476,11 +403,6 @@
动态模式未运行
-
- 通风 -- - 加热 -- - 按摩 -- -
@@ -576,7 +498,6 @@
-
+ +
diff --git a/apps/web-console/login.html b/apps/web-console/login.html index 2bf35cd..324e1fe 100755 --- a/apps/web-console/login.html +++ b/apps/web-console/login.html @@ -6,7 +6,7 @@ 登录 · Cognitive Agent - + diff --git a/apps/web-console/network.js b/apps/web-console/network.js index 5d666e9..b527259 100644 --- a/apps/web-console/network.js +++ b/apps/web-console/network.js @@ -12,30 +12,11 @@ return { ...state }; } - function renderBadge() { - const badge = document.querySelector("#connectionBadge"); - if (!badge) return; - const presentation = { - connecting: ["neutral", "loader-circle", "连接中"], - online: ["", "wifi", "服务已连接"], - degraded: ["warning", "wifi-low", "连接不稳定"], - offline: ["danger", "wifi-off", "服务已断开"], - }[state.status]; - badge.className = `status-badge connection-badge ${presentation[0]}`.trim(); - badge.innerHTML = `${presentation[2]}`; - badge.title = state.lastError || presentation[2]; - window.lucide?.createIcons?.({ - attrs: { "aria-hidden": "true" }, - nodes: [badge], - }); - } - function publish(status, error = null) { const changed = state.status !== status || state.lastError !== error; state.status = status; state.lastError = error; if (status === "online") state.lastSuccessAt = new Date().toISOString(); - renderBadge(); if (!changed) return; const detail = snapshot(); listeners.forEach((listener) => listener(detail)); @@ -110,7 +91,6 @@ function startMonitoring() { if (probeTimer !== null) return; - renderBadge(); probe(); probeTimer = window.setInterval(probe, 10000); window.addEventListener("online", () => { diff --git a/apps/web-console/pwa.js b/apps/web-console/pwa.js index f9bb641..63ac17b 100644 --- a/apps/web-console/pwa.js +++ b/apps/web-console/pwa.js @@ -4,12 +4,8 @@ } function showUpdate(registration) { - const badge = document.querySelector("#pwaBadge"); - if (!badge) return; - badge.hidden = false; - badge.innerHTML = '发现更新'; - badge.onclick = () => registration.waiting?.postMessage({ type: "SKIP_WAITING" }); - window.lucide?.createIcons?.({ attrs: { "aria-hidden": "true" }, nodes: [badge] }); + notify("检测到前端更新,正在刷新页面"); + registration.waiting?.postMessage({ type: "SKIP_WAITING" }); } async function register() { diff --git a/apps/web-console/self-extension.css b/apps/web-console/self-extension.css index 3a5b58f..0dd80bc 100644 --- a/apps/web-console/self-extension.css +++ b/apps/web-console/self-extension.css @@ -14,8 +14,6 @@ display: none; } -.console-v2[data-module="self-extension"] .v2-control-section, -.console-v2[data-module="self-extension"] .v2-config-section, .console-v2[data-module="self-extension"] .v2-sidebar > .v2-sidebar-footer:not(.v2-extension-footer), .console-v2[data-module="self-extension"] .v2-operation-shell, .console-v2[data-module="self-extension"] .v2-output-shell { diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index 0de4227..e22b3ff 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -1,16 +1,16 @@ -const CACHE_VERSION = "cognitive-agent-pad-v179"; +const CACHE_VERSION = "cognitive-agent-pad-v202"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ "/", "/index.html", - "/styles.css?v=dual-seat-10", - "/network.js?v=android-pad-1", - "/app.js?v=dual-seat-90", - "/gesture-control.js?v=android-pad-8", - "/pwa.js?v=android-pad-2", - "/console-v2.css?v=sync-layout-27", - "/console-v2.js?v=sync-layout-27", + "/styles.css?v=dual-seat-16", + "/network.js?v=android-pad-2", + "/app.js?v=dual-seat-101", + "/gesture-control.js?v=android-pad-9", + "/pwa.js?v=android-pad-3", + "/console-v2.css?v=sync-layout-40", + "/console-v2.js?v=sync-layout-41", "/self-extension.css?v=sync-layout-26", "/self-extension.js?v=sync-layout-26", "/assets/pets/self-extension-cat.png?v=3", diff --git a/apps/web-console/styles.css b/apps/web-console/styles.css index 0d4d8cb..407dd46 100755 --- a/apps/web-console/styles.css +++ b/apps/web-console/styles.css @@ -45,51 +45,11 @@ body.gesture-open { overflow: hidden; } -body.mode-gallery-open { +body.mode-gallery-open, +body.mode-editor-open { overflow: hidden; } -.topbar { - min-height: 76px; - padding: 12px clamp(18px, 2.4vw, 38px); - background: rgba(255, 255, 255, 0.86); - border-bottom-color: rgba(15, 72, 68, 0.1); - backdrop-filter: blur(18px) saturate(150%); - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8); -} - -.brand-mark { - border-radius: 14px; - background: linear-gradient(145deg, #0b8d82, #07655f); - box-shadow: 0 9px 22px rgba(8, 118, 110, 0.24); -} - -.runtime-badges { - gap: 9px; -} - -.gesture-entry { - min-height: 38px; - display: inline-flex; - align-items: center; - gap: 8px; - padding: 0 14px; - border: 1px solid rgba(8, 118, 110, 0.2); - border-radius: 999px; - color: var(--accent-ink); - background: linear-gradient(180deg, #f5fffc, #e8f7f3); - font-weight: 750; - cursor: pointer; - transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease; -} - -.gesture-entry:hover { - transform: translateY(-1px); - border-color: rgba(8, 118, 110, 0.42); - box-shadow: 0 8px 20px rgba(8, 118, 110, 0.12); -} - -.gesture-entry-dot, .gesture-state-pill > i, .gesture-live-badge > i { width: 7px; @@ -542,12 +502,10 @@ body.mode-gallery-open { .mode-gallery-card-shell > .mode-gallery-card { width: 100%; height: 100%; -} - -.mode-gallery-card-shell[data-mode-origin="user"] > .mode-gallery-card { padding-right: 35px; } +.mode-gallery-edit, .mode-gallery-delete { width: 25px; height: 25px; @@ -565,22 +523,42 @@ body.mode-gallery-open { transition: color 140ms ease, background 140ms ease, border-color 140ms ease; } +.mode-gallery-edit { + border-color: rgba(17, 117, 109, 0.16); + color: var(--v2-accent-strong, var(--accent-ink)); + background: rgba(239, 250, 248, 0.96); +} + +.mode-gallery-edit:hover { + color: #075e58; + background: #ddf4f0; + border-color: rgba(17, 117, 109, 0.32); +} + .mode-gallery-delete:hover { color: #8f2e28; background: #ffe7e3; border-color: rgba(168, 64, 57, 0.32); } +.mode-gallery-delete { + top: auto; + bottom: 8px; +} + +.mode-gallery-edit:focus-visible, .mode-gallery-delete:focus-visible { outline: 2px solid rgba(168, 64, 57, 0.28); outline-offset: 2px; } +.mode-gallery-edit:disabled, .mode-gallery-delete:disabled { opacity: 0.52; cursor: wait; } +.mode-gallery-edit svg, .mode-gallery-delete svg { width: 12px; height: 12px; @@ -744,6 +722,501 @@ body.mode-gallery-open { color: var(--v2-accent, var(--accent)); } +.mode-editor-backdrop { + position: fixed; + inset: 0; + z-index: 1400; + display: grid; + place-items: center; + padding: clamp(14px, 3vw, 32px); + background: rgba(10, 31, 32, 0.62); + backdrop-filter: blur(10px); +} + +.mode-editor-backdrop[hidden] { + display: none; +} + +.mode-editor-backdrop.flow-expanded { + padding: 0; + backdrop-filter: none; +} + +.mode-editor { + width: min(760px, calc(100vw - 28px)); + height: min(900px, calc(100dvh - 28px)); + max-height: calc(100dvh - 28px); + display: grid; + grid-template-rows: minmax(0, 30%) minmax(0, 1fr) auto auto; + gap: 10px; + padding: clamp(18px, 2.5vw, 26px); + overflow: hidden; + color: #183638; + background: #f8fbfa; + border: 1px solid rgba(255, 255, 255, 0.86); + border-radius: 22px; + box-shadow: 0 32px 100px rgba(7, 31, 32, 0.34); + opacity: 0; + transform: translateY(10px) scale(0.985); + transition: opacity 160ms ease, transform 160ms ease, grid-template-rows 180ms ease; +} + +.mode-editor.flow-expanded { + width: 100vw; + height: 100dvh; + max-height: 100dvh; + grid-template-rows: minmax(0, 1fr); + gap: 0; + padding: 0; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.mode-editor.flow-expanded .mode-editor-setup, +.mode-editor.flow-expanded .mode-editor-status, +.mode-editor.flow-expanded .mode-editor-actions { + display: none; +} + +.mode-editor.flow-expanded .mode-editor-flow { + border: 0; + border-radius: 0; +} + +.mode-editor-setup { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 9px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; +} + +.mode-editor-backdrop.open .mode-editor { + opacity: 1; + transform: translateY(0) scale(1); +} + +.mode-editor-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.mode-editor-header > div > span { + color: var(--v2-accent, var(--accent)); + font-size: 9px; + font-weight: 900; + letter-spacing: 0.16em; +} + +.mode-editor-header h2 { + margin: 3px 0 0; + font-size: clamp(21px, 2.4vw, 28px); + letter-spacing: -0.035em; +} + +.mode-editor-header p { + margin: 5px 0 0; + color: #748485; + font-size: 11px; +} + +.mode-editor-header button { + width: 36px; + height: 36px; + flex: 0 0 auto; + display: inline-grid; + place-items: center; + padding: 0; + border: 1px solid rgba(22, 75, 73, 0.13); + border-radius: 11px; + color: #536768; + background: #fff; + cursor: pointer; +} + +.mode-editor-header button svg { + width: 17px; +} + +.mode-editor-inputs { + min-height: 0; + display: grid; + grid-template-columns: minmax(150px, 0.32fr) minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + gap: 8px; +} + +.mode-editor-field { + min-height: 0; + display: grid; + gap: 6px; + color: #526768; + font-size: 10px; + font-weight: 800; +} + +.mode-editor-field input, +.mode-editor-field textarea { + width: 100%; + padding: 11px 12px; + border: 1px solid rgba(25, 90, 86, 0.16); + border-radius: 11px; + outline: none; + color: #173638; + background: #fff; + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 1.6; +} + +.mode-editor-name-field input { + height: 38px; +} + +.mode-editor-instruction-field { + grid-template-rows: auto minmax(0, 1fr); +} + +.mode-editor-instruction-field textarea { + min-height: 64px; + height: 100%; + overflow: auto; + resize: none; +} + +.mode-editor-field input:focus, +.mode-editor-field textarea:focus { + border-color: color-mix(in srgb, var(--v2-accent, var(--accent)) 58%, white); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--v2-accent-soft, var(--accent-wash)) 70%, transparent); +} + +.mode-editor-flow { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid rgba(25, 90, 86, 0.12); + border-radius: 12px; + background: #edf5f3; +} + +.mode-editor-flow header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 11px; + color: #d5efeb; + background: rgba(255, 255, 255, 0.045); + font-size: 10px; +} + +.mode-editor-flow header span { + display: inline-flex; + align-items: center; + gap: 7px; + font-weight: 800; +} + +.mode-editor-flow header svg { + width: 14px; + color: #71d6c9; +} + +.mode-editor-flow header b { + color: #85ddd2; + font-size: 9px; +} + +.mode-editor-flow-tools { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.mode-editor-flow-tools button { + width: 28px; + height: 28px; + display: inline-grid; + place-items: center; + padding: 0; + border: 1px solid rgba(20, 118, 110, 0.2); + border-radius: 8px; + color: #08746d; + background: rgba(255, 255, 255, 0.72); + cursor: pointer; +} + +.mode-editor-flow-tools button:hover, +.mode-editor-flow-tools button:focus-visible { + color: #055e58; + border-color: rgba(20, 118, 110, 0.38); + background: #fff; +} + +.mode-editor-flow-tools button:focus-visible { + outline: 2px solid rgba(20, 118, 110, 0.2); + outline-offset: 2px; +} + +.mode-editor-flow-tools button svg { + width: 14px; + height: 14px; +} + +.mode-editor-flow-preview { + min-height: 0; + padding: 12px; + overflow-x: hidden; + overflow-y: auto; + display: block; + color: #183e3c; + overscroll-behavior: contain; + scrollbar-gutter: stable; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; +} + +.mode-editor-flow-preview.empty { + display: grid; + place-items: center; + color: #738a88; +} + +.mode-editor-command-card + .mode-editor-command-card { + margin-top: 10px; +} + +.mode-editor-flow-preview > p { + margin: 0; + padding: 20px 12px; + font-size: 11px; + text-align: center; +} + +.mode-editor-command-card { + min-width: 0; + height: max-content; + overflow: hidden; + border: 1px solid rgba(18, 104, 98, 0.08); + border-radius: 14px; + background: #f5f9f8; + box-shadow: 0 3px 10px rgba(27, 73, 69, 0.035); +} + +.mode-editor-command-card > summary { + min-height: 74px; + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) 18px; + grid-template-rows: auto auto; + align-content: center; + gap: 8px 12px; + padding: 13px 15px; + list-style: none; + cursor: pointer; +} + +.mode-editor-command-card > summary::-webkit-details-marker { + display: none; +} + +.mode-editor-command-meta { + min-width: 0; + display: flex; + align-items: center; + gap: 9px; +} + +.mode-editor-command-meta b { + flex: 0 0 auto; + min-width: 48px; + padding: 3px 9px; + color: #086e68; + background: #ddf3f0; + border: 1px solid rgba(8, 110, 104, 0.14); + border-radius: 999px; + font: 850 10px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; + letter-spacing: 0.04em; + text-align: center; +} + +.mode-editor-command-card[data-can-channel="CAN0"] .mode-editor-command-meta b { + color: #8b5c13; + background: #f8eacb; + border-color: rgba(139, 92, 19, 0.14); +} + +.mode-editor-command-meta strong { + overflow: hidden; + color: #718583; + font-size: 11px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mode-editor-command-card > summary > code { + min-width: 0; + overflow-wrap: anywhere; + color: #00645e; + font: 800 14px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.mode-editor-command-card > summary > svg { + width: 16px; + height: 16px; + grid-column: 2; + grid-row: 1 / 3; + align-self: center; + color: #76908e; + transition: transform 150ms ease; +} + +.mode-editor-command-card[open] > summary > svg { + transform: rotate(180deg); +} + +.mode-editor-message-sequence { + display: grid; + gap: 9px; + padding: 11px 13px 13px; + border-top: 1px solid rgba(18, 104, 98, 0.09); + background: rgba(255, 255, 255, 0.72); +} + +.mode-editor-message-sequence > header { + padding: 0; + color: #647c7a; + background: transparent; + font-size: 9px; +} + +.mode-editor-message-sequence > header b { + color: #0b7770; +} + +.mode-editor-message-sequence > p { + margin: 0; + color: #809391; + font-size: 10px; +} + +.mode-editor-message-sequence ol { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.mode-editor-message-sequence li { + min-width: 0; + overflow: hidden; + border: 1px solid rgba(15, 89, 84, 0.1); + border-radius: 10px; + background: #102b2d; +} + +.mode-editor-message-sequence li > div { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding: 7px 9px; + color: #8bd4cd; + background: rgba(255, 255, 255, 0.045); + font: 750 8px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.mode-editor-message-sequence li > div code { + min-width: 0; + overflow: visible; + color: #c2e9e4; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; +} + +.mode-editor-message-sequence li > pre { + margin: 0; + padding: 9px; + overflow: visible; + color: #c9e8e4; + font: 9px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; + white-space: pre-wrap; + word-break: break-word; +} + +.mode-editor.flow-expanded .mode-editor-command-meta strong { + overflow: visible; + text-overflow: clip; + white-space: normal; +} + +.mode-editor.flow-expanded .mode-editor-flow-preview { + padding: clamp(12px, 2vw, 24px); +} + +.mode-editor-status { + min-height: 16px; + margin: 0; + color: #6f8081; + font-size: 10px; + line-height: 1.5; +} + +.mode-editor-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.mode-editor-actions button { + min-width: 90px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 16px; + border: 1px solid rgba(24, 83, 80, 0.15); + border-radius: 10px; + color: #466061; + background: #fff; + font-size: 11px; + font-weight: 800; + cursor: pointer; +} + +.mode-editor-actions button svg { + width: 14px; +} + +.mode-editor-actions .mode-editor-execute { + color: #075f59; + background: #e2f6f2; + border-color: rgba(13, 125, 116, 0.2); +} + +.mode-editor-actions .mode-editor-complete { + color: #fff; + background: var(--v2-accent, var(--accent)); +} + +.mode-editor-actions button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .save-mode-composer { display: grid; grid-template-columns: minmax(0, 1fr); @@ -868,42 +1341,11 @@ body.mode-gallery-open { box-shadow: 0 17px 34px rgba(8, 118, 110, 0.26); } -.example-group { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 7px; -} - -.example-group > .field-label { - grid-column: 1 / -1; -} - -.example-command { - min-width: 0; - min-height: 44px; - padding: 8px 9px; - border: 1px solid rgba(37, 79, 75, 0.09); - border-radius: 11px; - background: rgba(255, 255, 255, 0.7); -} - -.example-command span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 11px; -} - -.example-command > svg:last-child { - display: none; -} - .main-panel { padding: 25px clamp(20px, 2.4vw, 38px) 60px; } .request-header, -.pipeline, .joint-twin-shell, .result-workspace { border-radius: 18px; @@ -915,17 +1357,6 @@ body.mode-gallery-open { border: 1px solid rgba(24, 70, 67, 0.09); } -.pipeline { - padding: 11px; - background: rgba(255, 255, 255, 0.72); - border: 1px solid rgba(24, 70, 67, 0.09); - box-shadow: var(--soft-shadow); -} - -.pipeline-step { - border-radius: 12px; -} - .domain-panel, .seat-card, .result-workspace, @@ -934,20 +1365,6 @@ body.mode-gallery-open { box-shadow: 0 14px 36px rgba(27, 58, 58, 0.05); } -.gesture-backdrop { - position: fixed; - inset: 0; - z-index: 80; - background: rgba(9, 23, 25, 0.32); - opacity: 0; - backdrop-filter: blur(3px); - transition: opacity 220ms ease; -} - -.gesture-backdrop.visible { - opacity: 1; -} - .gesture-drawer { position: fixed; top: 0; @@ -1366,28 +1783,9 @@ body.mode-gallery-open { grid-template-columns: 330px minmax(0, 1fr); } - .runtime-badges .status-badge:not(#executionBadge) { - display: none; - } } @media (max-width: 820px) { - .topbar { - position: sticky; - padding: 10px 14px; - } - - .brand-block p, - .status-badge { - display: none; - } - - .gesture-entry { - min-height: 36px; - padding: 0 10px; - font-size: 11px; - } - .workspace { display: block; } @@ -1404,10 +1802,6 @@ body.mode-gallery-open { padding: 18px 12px 45px; } - .example-group { - grid-template-columns: 1fr 1fr; - } - .gesture-drawer { width: 100vw; padding: 18px 15px; @@ -1434,6 +1828,59 @@ body.mode-gallery-open { .mode-gallery-footer { display: none; } + + .mode-editor { + height: calc(100dvh - 18px); + max-height: calc(100dvh - 18px); + grid-template-rows: minmax(0, 30%) minmax(0, 1fr) auto auto; + gap: 8px; + padding: 15px; + border-radius: 17px; + } + + .mode-editor-setup { + gap: 7px; + } + + .mode-editor-inputs { + grid-template-columns: minmax(110px, 0.38fr) minmax(0, 1fr); + gap: 6px; + } + + .mode-editor-header p { + display: none; + } + + .mode-editor-actions button { + min-width: 0; + flex: 1 1 0; + padding: 0 10px; + } + + .mode-editor.flow-expanded { + width: 100vw; + height: 100dvh; + max-height: 100dvh; + grid-template-rows: minmax(0, 1fr); + padding: 0; + border-radius: 0; + } +} + +@media (max-height: 700px) { + .mode-editor-header > div > span, + .mode-editor-header p { + display: none; + } + + .mode-editor-header h2 { + margin-top: 0; + font-size: 20px; + } + + .mode-editor-setup { + gap: 6px; + } } @media (prefers-reduced-motion: reduce) { @@ -1474,24 +1921,6 @@ svg { stroke-width: 1.8; } -.topbar { - min-height: 70px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - padding: 12px 24px; - background: var(--surface); - border-bottom: 1px solid var(--line); - position: sticky; - top: 0; - z-index: 20; -} - -.brand-block, -.brand-mark, -.runtime-badges, -.status-badge, .request-title-row, .matrix-stat, .section-title-row, @@ -1502,8 +1931,6 @@ svg { .frame-summary, .execution-summary, .result-item, -.safety-strip, -.example-command, .primary-action, .segment, .tab { @@ -1511,97 +1938,6 @@ svg { align-items: center; } -.brand-block { - gap: 12px; -} - -.brand-mark { - width: 42px; - height: 42px; - justify-content: center; - color: white; - background: var(--teal); - border-radius: 6px; -} - -.brand-mark svg { - width: 25px; - height: 25px; -} - -.brand-block h1 { - margin: 0; - font-size: 19px; - line-height: 1.25; -} - -.brand-block p { - margin: 4px 0 0; - color: var(--muted); - font-size: 10px; - font-weight: 800; -} - -.brand-block p span { - padding: 0 5px; - color: var(--line-strong); -} - -.runtime-badges { - flex-wrap: wrap; - justify-content: flex-end; - gap: 8px; -} - -.status-badge { - min-height: 30px; - gap: 6px; - padding: 5px 9px; - border: 1px solid #b8d8d3; - border-radius: 5px; - background: var(--teal-soft); - color: var(--teal-dark); - font-size: 11px; - font-weight: 800; - white-space: nowrap; -} - -.auth-logout { - min-width: 31px; - justify-content: center; - padding: 5px; - color: var(--muted); - background: var(--surface-muted); - border: 1px solid var(--line); - cursor: pointer; -} - -.auth-logout:hover { - color: var(--red); - border-color: #e1b7bb; -} - -.auth-logout[hidden] { - display: none; -} - -.status-badge svg { - width: 14px; - height: 14px; -} - -.status-badge.neutral { - color: #42545e; - background: #eef2f3; - border-color: #d6dee1; -} - -.status-badge.danger { - color: var(--red); - background: var(--red-soft); - border-color: #efc6c9; -} - .workspace { min-height: calc(100vh - 70px); display: grid; @@ -1825,73 +2161,6 @@ textarea:focus { animation: spin 0.9s linear infinite; } -.example-group { - margin-top: 26px; - padding-top: 20px; - border-top: 1px solid var(--line); -} - -.example-command { - width: 100%; - min-height: 42px; - gap: 9px; - padding: 8px 0; - border: 0; - border-bottom: 1px solid #e9eeec; - background: transparent; - text-align: left; - cursor: pointer; -} - -.example-command span { - flex: 1; - min-width: 0; - font-size: 12px; -} - -.example-command svg:first-child { - color: var(--teal); -} - -.example-command svg:last-child { - width: 15px; - color: #9aa7ac; -} - -.warning-example svg:first-child { - color: var(--amber); -} - -.safety-strip { - align-items: flex-start; - gap: 10px; - margin-top: 22px; - padding: 12px; - color: #6c4b12; - background: var(--amber-soft); - border-left: 3px solid var(--amber); -} - -.safety-strip svg { - flex: 0 0 auto; - margin-top: 1px; -} - -.safety-strip strong, -.safety-strip span { - display: block; -} - -.safety-strip strong { - margin-bottom: 3px; - font-size: 12px; -} - -.safety-strip span { - font-size: 11px; - line-height: 1.5; -} - .main-panel { min-width: 0; padding: 24px 28px 40px; @@ -1982,71 +2251,6 @@ textarea:focus { font-size: 9px; } -.pipeline { - min-height: 92px; - display: grid; - grid-template-columns: repeat(5, minmax(84px, 1fr) 18px) minmax(84px, 1fr); - align-items: stretch; - padding: 11px; - background: var(--surface); - border: 1px solid var(--line); - box-shadow: var(--shadow); - overflow-x: auto; -} - -.pipeline-step { - min-width: 84px; - min-height: 68px; - display: grid; - grid-template-columns: 20px 1fr; - grid-template-rows: 1fr auto; - align-items: center; - gap: 2px 7px; - padding: 8px; - border-radius: 5px; - color: var(--muted); -} - -.pipeline-step span { - grid-row: 1 / 3; - align-self: start; - color: #a0aaae; - font: 700 9px "SFMono-Regular", Consolas, monospace; -} - -.pipeline-step svg { - width: 20px; - height: 20px; -} - -.pipeline-step b { - align-self: end; - font-size: 10px; - white-space: nowrap; -} - -.pipeline-step.active { - color: var(--blue); - background: var(--blue-soft); -} - -.pipeline-step.succeeded { - color: var(--teal-dark); - background: var(--teal-soft); -} - -.pipeline-step.failed { - color: var(--red); - background: var(--red-soft); -} - -.pipeline-arrow { - align-self: center; - justify-self: center; - width: 15px; - color: #b4bfbb; -} - .cabin-band { margin-top: 22px; padding: 20px 0 22px; @@ -4641,19 +4845,6 @@ pre { } @media (max-width: 760px) { - .topbar { - align-items: flex-start; - padding: 11px 14px; - } - - .runtime-badges .status-badge.neutral { - display: none; - } - - .brand-block h1 { - font-size: 16px; - } - .workspace { display: block; } @@ -4905,28 +5096,6 @@ pre { } @media (max-width: 460px) { - .topbar { - gap: 10px; - } - - .brand-mark { - width: 36px; - height: 36px; - } - - .runtime-badges { - gap: 4px; - } - - .status-badge { - padding: 5px 6px; - font-size: 9px; - } - - .status-badge svg { - display: none; - } - .request-header { display: block; } @@ -5102,24 +5271,6 @@ pre { font-weight: 700; } -.status-badge.warning { - color: #7a5416; - background: var(--amber-soft); - border-color: #e9cc96; -} - -.connection-badge svg[data-lucide="loader-circle"] { - animation: spin 0.9s linear infinite; -} - -.pwa-badge { - cursor: pointer; -} - -.pwa-badge[hidden] { - display: none; -} - @supports (min-height: 100dvh) { body { min-height: 100dvh; @@ -5136,15 +5287,11 @@ pre { input, .segment, .mode-option, - .tab, - .example-command, - .gesture-entry, - .status-badge.auth-logout { + .tab { min-height: 48px; } button:hover, - .example-command:hover, .segment:hover, .mode-option:hover { transform: none; @@ -5156,38 +5303,6 @@ pre { } @media (max-width: 900px) { - .topbar { - min-height: 64px; - align-items: center; - gap: 12px; - padding-top: max(10px, env(safe-area-inset-top)); - padding-right: max(14px, env(safe-area-inset-right)); - padding-left: max(14px, env(safe-area-inset-left)); - } - - .brand-block p, - #hardwareBadge, - #modelBadge { - display: none; - } - - .runtime-badges { - flex: 1; - flex-wrap: nowrap; - justify-content: flex-end; - overflow-x: auto; - overscroll-behavior-inline: contain; - scrollbar-width: none; - } - - .runtime-badges .status-badge#connectionBadge { - display: flex; - } - - .runtime-badges::-webkit-scrollbar { - display: none; - } - .workspace { display: block; } @@ -5208,15 +5323,6 @@ pre { padding-left: max(16px, env(safe-area-inset-left)); } - .pipeline { - overflow-x: auto; - overscroll-behavior-inline: contain; - } - - .pipeline-step { - min-width: 96px; - } - .gesture-drawer { width: min(520px, 100vw); padding-right: max(18px, env(safe-area-inset-right)); @@ -5229,20 +5335,9 @@ pre { .execution-mode-options { grid-template-columns: 1fr; } - - .runtime-badges #executionBadge, - .pwa-badge { - display: none; - } } @media (orientation: landscape) and (max-height: 720px) and (min-width: 901px) { - .topbar { - min-height: 58px; - padding-top: 8px; - padding-bottom: 8px; - } - .workspace { min-height: calc(100dvh - 58px); } diff --git a/artifacts/cognitive-agent-final.png b/artifacts/cognitive-agent-final.png deleted file mode 100755 index 3e7043c..0000000 Binary files a/artifacts/cognitive-agent-final.png and /dev/null differ diff --git a/artifacts/cognitive-agent-gesture-live.png b/artifacts/cognitive-agent-gesture-live.png deleted file mode 100755 index ca91a3e..0000000 Binary files a/artifacts/cognitive-agent-gesture-live.png and /dev/null differ diff --git a/artifacts/cognitive-agent-gesture-mobile.png b/artifacts/cognitive-agent-gesture-mobile.png deleted file mode 100755 index a78f06e..0000000 Binary files a/artifacts/cognitive-agent-gesture-mobile.png and /dev/null differ diff --git a/artifacts/cognitive-agent-mobile.png b/artifacts/cognitive-agent-mobile.png deleted file mode 100755 index 261fc5d..0000000 Binary files a/artifacts/cognitive-agent-mobile.png and /dev/null differ diff --git a/artifacts/public-authenticated-console.png b/artifacts/public-authenticated-console.png deleted file mode 100755 index be9b503..0000000 Binary files a/artifacts/public-authenticated-console.png and /dev/null differ diff --git a/artifacts/public-login.png b/artifacts/public-login.png deleted file mode 100755 index 73bb572..0000000 Binary files a/artifacts/public-login.png and /dev/null differ diff --git a/artifacts/seat-angle-desktop-canvas.png b/artifacts/seat-angle-desktop-canvas.png deleted file mode 100755 index 0164f0c..0000000 Binary files a/artifacts/seat-angle-desktop-canvas.png and /dev/null differ diff --git a/artifacts/seat-angle-desktop.png b/artifacts/seat-angle-desktop.png deleted file mode 100755 index 9fa85d6..0000000 Binary files a/artifacts/seat-angle-desktop.png and /dev/null differ diff --git a/artifacts/seat-angle-mobile-canvas.png b/artifacts/seat-angle-mobile-canvas.png deleted file mode 100755 index b875156..0000000 Binary files a/artifacts/seat-angle-mobile-canvas.png and /dev/null differ diff --git a/artifacts/seat-angle-mobile-final-canvas.png b/artifacts/seat-angle-mobile-final-canvas.png deleted file mode 100755 index b875156..0000000 Binary files a/artifacts/seat-angle-mobile-final-canvas.png and /dev/null differ diff --git a/artifacts/seat-angle-mobile-final.png b/artifacts/seat-angle-mobile-final.png deleted file mode 100755 index 037ede2..0000000 Binary files a/artifacts/seat-angle-mobile-final.png and /dev/null differ diff --git a/artifacts/seat-angle-mobile.png b/artifacts/seat-angle-mobile.png deleted file mode 100755 index 0a85bc3..0000000 Binary files a/artifacts/seat-angle-mobile.png and /dev/null differ diff --git a/artifacts/seat-cabin-angle-initial.png b/artifacts/seat-cabin-angle-initial.png deleted file mode 100755 index 76351f0..0000000 Binary files a/artifacts/seat-cabin-angle-initial.png and /dev/null differ diff --git a/artifacts/seat-cabin-current.png b/artifacts/seat-cabin-current.png deleted file mode 100755 index 7029403..0000000 Binary files a/artifacts/seat-cabin-current.png and /dev/null differ diff --git a/artifacts/seat-cabin-exact-angle-side.png b/artifacts/seat-cabin-exact-angle-side.png deleted file mode 100755 index ca4ddee..0000000 Binary files a/artifacts/seat-cabin-exact-angle-side.png and /dev/null differ diff --git a/artifacts/seat-cabin-exact-angle.png b/artifacts/seat-cabin-exact-angle.png deleted file mode 100755 index 24b521f..0000000 Binary files a/artifacts/seat-cabin-exact-angle.png and /dev/null differ diff --git a/artifacts/seat-motion-final.png b/artifacts/seat-motion-final.png deleted file mode 100755 index 1d78d40..0000000 Binary files a/artifacts/seat-motion-final.png and /dev/null differ diff --git a/artifacts/seat-motion-mobile.png b/artifacts/seat-motion-mobile.png deleted file mode 100755 index 6d29723..0000000 Binary files a/artifacts/seat-motion-mobile.png and /dev/null differ diff --git a/artifacts/seat-preset-config-desktop.png b/artifacts/seat-preset-config-desktop.png deleted file mode 100755 index 65c8482..0000000 Binary files a/artifacts/seat-preset-config-desktop.png and /dev/null differ diff --git a/artifacts/seat-preset-result-desktop.png b/artifacts/seat-preset-result-desktop.png deleted file mode 100755 index 61a6393..0000000 Binary files a/artifacts/seat-preset-result-desktop.png and /dev/null differ diff --git a/artifacts/seat-twin-canvas-desktop.png b/artifacts/seat-twin-canvas-desktop.png deleted file mode 100755 index 33a2d08..0000000 Binary files a/artifacts/seat-twin-canvas-desktop.png and /dev/null differ diff --git a/artifacts/seat-twin-canvas-mobile.png b/artifacts/seat-twin-canvas-mobile.png deleted file mode 100755 index 572bb04..0000000 Binary files a/artifacts/seat-twin-canvas-mobile.png and /dev/null differ diff --git a/artifacts/seat-twin-desktop.png b/artifacts/seat-twin-desktop.png deleted file mode 100755 index d9b1da7..0000000 Binary files a/artifacts/seat-twin-desktop.png and /dev/null differ diff --git a/artifacts/seat-twin-mobile.png b/artifacts/seat-twin-mobile.png deleted file mode 100755 index 47e4716..0000000 Binary files a/artifacts/seat-twin-mobile.png and /dev/null differ diff --git a/artifacts/seat-twin-v2-initial-desktop.png b/artifacts/seat-twin-v2-initial-desktop.png deleted file mode 100755 index 01b389f..0000000 Binary files a/artifacts/seat-twin-v2-initial-desktop.png and /dev/null differ diff --git a/artifacts/seat-twin-v2-initial-mobile.png b/artifacts/seat-twin-v2-initial-mobile.png deleted file mode 100755 index dc3ae4b..0000000 Binary files a/artifacts/seat-twin-v2-initial-mobile.png and /dev/null differ diff --git a/artifacts/twin-cabin-compact-driver.png b/artifacts/twin-cabin-compact-driver.png deleted file mode 100755 index 4b44920..0000000 Binary files a/artifacts/twin-cabin-compact-driver.png and /dev/null differ diff --git a/artifacts/twin-cabin-default-driver.png b/artifacts/twin-cabin-default-driver.png deleted file mode 100755 index 0d2bc71..0000000 Binary files a/artifacts/twin-cabin-default-driver.png and /dev/null differ diff --git a/artifacts/twin-cabin-depth-driver.png b/artifacts/twin-cabin-depth-driver.png deleted file mode 100755 index e50ed38..0000000 Binary files a/artifacts/twin-cabin-depth-driver.png and /dev/null differ diff --git a/artifacts/twin-cabin-depth-passenger.png b/artifacts/twin-cabin-depth-passenger.png deleted file mode 100755 index 04a9885..0000000 Binary files a/artifacts/twin-cabin-depth-passenger.png and /dev/null differ diff --git a/artifacts/twin-cabin-final-default-driver-canvas.png b/artifacts/twin-cabin-final-default-driver-canvas.png deleted file mode 100755 index 14f5ce7..0000000 Binary files a/artifacts/twin-cabin-final-default-driver-canvas.png and /dev/null differ diff --git a/artifacts/twin-cabin-final-default-driver-stage.png b/artifacts/twin-cabin-final-default-driver-stage.png deleted file mode 100755 index 977bd0b..0000000 Binary files a/artifacts/twin-cabin-final-default-driver-stage.png and /dev/null differ diff --git a/artifacts/twin-cabin-final-default.png b/artifacts/twin-cabin-final-default.png deleted file mode 100755 index 82e0f59..0000000 Binary files a/artifacts/twin-cabin-final-default.png and /dev/null differ diff --git a/artifacts/twin-cabin-final-desktop-canvas.png b/artifacts/twin-cabin-final-desktop-canvas.png deleted file mode 100755 index 89ccf70..0000000 Binary files a/artifacts/twin-cabin-final-desktop-canvas.png and /dev/null differ diff --git a/artifacts/twin-cabin-final-desktop-stage.png b/artifacts/twin-cabin-final-desktop-stage.png deleted file mode 100755 index c053337..0000000 Binary files a/artifacts/twin-cabin-final-desktop-stage.png and /dev/null differ diff --git a/artifacts/twin-cabin-final-driver.png b/artifacts/twin-cabin-final-driver.png deleted file mode 100755 index c041609..0000000 Binary files a/artifacts/twin-cabin-final-driver.png and /dev/null differ diff --git a/artifacts/twin-cabin-final-mobile-canvas.png b/artifacts/twin-cabin-final-mobile-canvas.png deleted file mode 100755 index 1fb9e27..0000000 Binary files a/artifacts/twin-cabin-final-mobile-canvas.png and /dev/null differ diff --git a/artifacts/twin-cabin-final-mobile-stage.png b/artifacts/twin-cabin-final-mobile-stage.png deleted file mode 100755 index 485e109..0000000 Binary files a/artifacts/twin-cabin-final-mobile-stage.png and /dev/null differ diff --git a/artifacts/twin-cabin-final-passenger.png b/artifacts/twin-cabin-final-passenger.png deleted file mode 100755 index 9924d2e..0000000 Binary files a/artifacts/twin-cabin-final-passenger.png and /dev/null differ diff --git a/artifacts/twin-cabin-proportioned-driver.png b/artifacts/twin-cabin-proportioned-driver.png deleted file mode 100755 index 90c8ab4..0000000 Binary files a/artifacts/twin-cabin-proportioned-driver.png and /dev/null differ diff --git a/artifacts/twin-cabin-proportioned-passenger.png b/artifacts/twin-cabin-proportioned-passenger.png deleted file mode 100755 index e323df5..0000000 Binary files a/artifacts/twin-cabin-proportioned-passenger.png and /dev/null differ diff --git a/artifacts/twin-cabin-rework-driver.png b/artifacts/twin-cabin-rework-driver.png deleted file mode 100755 index cea5e0f..0000000 Binary files a/artifacts/twin-cabin-rework-driver.png and /dev/null differ diff --git a/artifacts/twin-cabin-rework-passenger.png b/artifacts/twin-cabin-rework-passenger.png deleted file mode 100755 index 8d98fd2..0000000 Binary files a/artifacts/twin-cabin-rework-passenger.png and /dev/null differ diff --git a/artifacts/twin-cabin-solid-driver.png b/artifacts/twin-cabin-solid-driver.png deleted file mode 100755 index c3bdd54..0000000 Binary files a/artifacts/twin-cabin-solid-driver.png and /dev/null differ diff --git a/artifacts/twin-cabin-solid-passenger.png b/artifacts/twin-cabin-solid-passenger.png deleted file mode 100755 index f3a208b..0000000 Binary files a/artifacts/twin-cabin-solid-passenger.png and /dev/null differ diff --git a/artifacts/twin-cabin-spacing-final.png b/artifacts/twin-cabin-spacing-final.png deleted file mode 100755 index bef862d..0000000 Binary files a/artifacts/twin-cabin-spacing-final.png and /dev/null differ diff --git a/artifacts/twin-cabin-thin-dashboard.png b/artifacts/twin-cabin-thin-dashboard.png deleted file mode 100755 index 42f3588..0000000 Binary files a/artifacts/twin-cabin-thin-dashboard.png and /dev/null differ diff --git a/artifacts/twin-default-desktop-final.png b/artifacts/twin-default-desktop-final.png deleted file mode 100755 index 2ede59d..0000000 Binary files a/artifacts/twin-default-desktop-final.png and /dev/null differ diff --git a/artifacts/twin-default-five-axis.png b/artifacts/twin-default-five-axis.png deleted file mode 100755 index c0f6ca3..0000000 Binary files a/artifacts/twin-default-five-axis.png and /dev/null differ diff --git a/artifacts/twin-driver-extreme-spacing.png b/artifacts/twin-driver-extreme-spacing.png deleted file mode 100755 index 66b2e22..0000000 Binary files a/artifacts/twin-driver-extreme-spacing.png and /dev/null differ diff --git a/artifacts/twin-driver-spacing-final.png b/artifacts/twin-driver-spacing-final.png deleted file mode 100755 index 6872d51..0000000 Binary files a/artifacts/twin-driver-spacing-final.png and /dev/null differ diff --git a/artifacts/twin-explicit-desktop-final-canvas.png b/artifacts/twin-explicit-desktop-final-canvas.png deleted file mode 100755 index a299fb3..0000000 Binary files a/artifacts/twin-explicit-desktop-final-canvas.png and /dev/null differ diff --git a/artifacts/twin-explicit-desktop-final.png b/artifacts/twin-explicit-desktop-final.png deleted file mode 100755 index 46bd924..0000000 Binary files a/artifacts/twin-explicit-desktop-final.png and /dev/null differ diff --git a/artifacts/twin-explicit-desktop-verified.png b/artifacts/twin-explicit-desktop-verified.png deleted file mode 100755 index a377d12..0000000 Binary files a/artifacts/twin-explicit-desktop-verified.png and /dev/null differ diff --git a/artifacts/twin-explicit-final.png b/artifacts/twin-explicit-final.png deleted file mode 100755 index c3f2cfa..0000000 Binary files a/artifacts/twin-explicit-final.png and /dev/null differ diff --git a/artifacts/twin-explicit-included-angles.png b/artifacts/twin-explicit-included-angles.png deleted file mode 100755 index b0070d1..0000000 Binary files a/artifacts/twin-explicit-included-angles.png and /dev/null differ diff --git a/artifacts/twin-explicit-mobile-adjusted.png b/artifacts/twin-explicit-mobile-adjusted.png deleted file mode 100755 index 828167b..0000000 Binary files a/artifacts/twin-explicit-mobile-adjusted.png and /dev/null differ diff --git a/artifacts/twin-explicit-mobile-final-canvas.png b/artifacts/twin-explicit-mobile-final-canvas.png deleted file mode 100755 index 399c06e..0000000 Binary files a/artifacts/twin-explicit-mobile-final-canvas.png and /dev/null differ diff --git a/artifacts/twin-explicit-mobile-final.png b/artifacts/twin-explicit-mobile-final.png deleted file mode 100755 index af7bcf1..0000000 Binary files a/artifacts/twin-explicit-mobile-final.png and /dev/null differ diff --git a/artifacts/twin-explicit-mobile-verified.png b/artifacts/twin-explicit-mobile-verified.png deleted file mode 100755 index c852b8a..0000000 Binary files a/artifacts/twin-explicit-mobile-verified.png and /dev/null differ diff --git a/artifacts/twin-final-desktop.png b/artifacts/twin-final-desktop.png deleted file mode 100755 index 2fc4600..0000000 Binary files a/artifacts/twin-final-desktop.png and /dev/null differ diff --git a/artifacts/twin-final-mobile.png b/artifacts/twin-final-mobile.png deleted file mode 100755 index 6ababb6..0000000 Binary files a/artifacts/twin-final-mobile.png and /dev/null differ diff --git a/artifacts/twin-legrest-worst-clearance.png b/artifacts/twin-legrest-worst-clearance.png deleted file mode 100755 index d62855c..0000000 Binary files a/artifacts/twin-legrest-worst-clearance.png and /dev/null differ diff --git a/artifacts/twin-thermal-final-desktop-heat-canvas.png b/artifacts/twin-thermal-final-desktop-heat-canvas.png deleted file mode 100755 index 643e157..0000000 Binary files a/artifacts/twin-thermal-final-desktop-heat-canvas.png and /dev/null differ diff --git a/artifacts/twin-thermal-final-desktop-heat-stage.png b/artifacts/twin-thermal-final-desktop-heat-stage.png deleted file mode 100755 index 015f7ab..0000000 Binary files a/artifacts/twin-thermal-final-desktop-heat-stage.png and /dev/null differ diff --git a/artifacts/twin-thermal-final-desktop-vent-canvas.png b/artifacts/twin-thermal-final-desktop-vent-canvas.png deleted file mode 100755 index 18a7f99..0000000 Binary files a/artifacts/twin-thermal-final-desktop-vent-canvas.png and /dev/null differ diff --git a/artifacts/twin-thermal-final-desktop-vent-stage.png b/artifacts/twin-thermal-final-desktop-vent-stage.png deleted file mode 100755 index d769756..0000000 Binary files a/artifacts/twin-thermal-final-desktop-vent-stage.png and /dev/null differ diff --git a/artifacts/twin-thermal-final-mobile-passenger-heat-canvas.png b/artifacts/twin-thermal-final-mobile-passenger-heat-canvas.png deleted file mode 100755 index b41bfa6..0000000 Binary files a/artifacts/twin-thermal-final-mobile-passenger-heat-canvas.png and /dev/null differ diff --git a/artifacts/twin-thermal-final-mobile-passenger-heat-stage.png b/artifacts/twin-thermal-final-mobile-passenger-heat-stage.png deleted file mode 100755 index 0fe3b77..0000000 Binary files a/artifacts/twin-thermal-final-mobile-passenger-heat-stage.png and /dev/null differ diff --git a/artifacts/twin-thermal-heating-l3.png b/artifacts/twin-thermal-heating-l3.png deleted file mode 100755 index 555dacf..0000000 Binary files a/artifacts/twin-thermal-heating-l3.png and /dev/null differ diff --git a/artifacts/twin-thermal-refined-heating.png b/artifacts/twin-thermal-refined-heating.png deleted file mode 100755 index a42bcf3..0000000 Binary files a/artifacts/twin-thermal-refined-heating.png and /dev/null differ diff --git a/artifacts/twin-thermal-refined-ventilation.png b/artifacts/twin-thermal-refined-ventilation.png deleted file mode 100755 index 003ae3f..0000000 Binary files a/artifacts/twin-thermal-refined-ventilation.png and /dev/null differ diff --git a/artifacts/twin-thermal-ventilation-l1.png b/artifacts/twin-thermal-ventilation-l1.png deleted file mode 100755 index 286fbfa..0000000 Binary files a/artifacts/twin-thermal-ventilation-l1.png and /dev/null differ diff --git a/artifacts/twin-thermal-ventilation-l3.png b/artifacts/twin-thermal-ventilation-l3.png deleted file mode 100755 index 235dc8d..0000000 Binary files a/artifacts/twin-thermal-ventilation-l3.png and /dev/null differ diff --git a/artifacts/twin-visual-desktop-explicit-final-canvas.png b/artifacts/twin-visual-desktop-explicit-final-canvas.png deleted file mode 100755 index d972317..0000000 Binary files a/artifacts/twin-visual-desktop-explicit-final-canvas.png and /dev/null differ diff --git a/artifacts/twin-visual-desktop-explicit-final-stage.png b/artifacts/twin-visual-desktop-explicit-final-stage.png deleted file mode 100755 index 07cba75..0000000 Binary files a/artifacts/twin-visual-desktop-explicit-final-stage.png and /dev/null differ diff --git a/artifacts/twin-visual-desktop-final-canvas.png b/artifacts/twin-visual-desktop-final-canvas.png deleted file mode 100755 index 15d67cd..0000000 Binary files a/artifacts/twin-visual-desktop-final-canvas.png and /dev/null differ diff --git a/artifacts/twin-visual-desktop-final-stage.png b/artifacts/twin-visual-desktop-final-stage.png deleted file mode 100755 index 3cd73cb..0000000 Binary files a/artifacts/twin-visual-desktop-final-stage.png and /dev/null differ diff --git a/artifacts/twin-visual-mobile-default-final-canvas.png b/artifacts/twin-visual-mobile-default-final-canvas.png deleted file mode 100755 index 0cb4817..0000000 Binary files a/artifacts/twin-visual-mobile-default-final-canvas.png and /dev/null differ diff --git a/artifacts/twin-visual-mobile-default-final-stage.png b/artifacts/twin-visual-mobile-default-final-stage.png deleted file mode 100755 index c5ca376..0000000 Binary files a/artifacts/twin-visual-mobile-default-final-stage.png and /dev/null differ diff --git a/artifacts/twin-visual-mobile-explicit-canvas.png b/artifacts/twin-visual-mobile-explicit-canvas.png deleted file mode 100755 index e5640e3..0000000 Binary files a/artifacts/twin-visual-mobile-explicit-canvas.png and /dev/null differ diff --git a/artifacts/twin-visual-mobile-explicit-stage.png b/artifacts/twin-visual-mobile-explicit-stage.png deleted file mode 100755 index 7666583..0000000 Binary files a/artifacts/twin-visual-mobile-explicit-stage.png and /dev/null differ diff --git a/config/environments/linux_vehicle_state.example.env b/config/environments/linux_vehicle_state.example.env index d4f46e1..c9f7470 100644 --- a/config/environments/linux_vehicle_state.example.env +++ b/config/environments/linux_vehicle_state.example.env @@ -13,11 +13,13 @@ COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY=/home/orin/project511/project-code/canfd/li COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED=false # Local NanoMQ wireless dual-CAN relay: CAN0 is the passenger seat; CAN1 is the -# rest of the vehicle. State subscription remains available while physical -# control stays locked unless the existing hardware approval policy is configured. +# rest of the vehicle. The local relay is executable by default; set the MQTT +# approval flag below to true and configure the shared approval token when a +# deployment needs an additional server-side transmit gate. COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED=true +COGNITIVE_AGENT_VEHICLE_MQTT_APPROVAL_TOKEN_REQUIRED=false COGNITIVE_AGENT_VEHICLE_MQTT_HOST=127.0.0.1 COGNITIVE_AGENT_VEHICLE_MQTT_PORT=1883 COGNITIVE_AGENT_VEHICLE_MQTT_CONDITION_TOPIC=n7/n7-headunit/can/state -COGNITIVE_AGENT_VEHICLE_MQTT_CONTROL_TOPIC=n7/can_control +COGNITIVE_AGENT_VEHICLE_MQTT_CONTROL_TOPIC=n7/n7-headunit/can/command COGNITIVE_AGENT_VEHICLE_MQTT_QOS=1 diff --git a/config/native_can_capabilities/door.yaml b/config/native_can_capabilities/door.yaml index 407c1c8..3795669 100644 --- a/config/native_can_capabilities/door.yaml +++ b/config/native_can_capabilities/door.yaml @@ -3,7 +3,7 @@ kind: CapabilityCatalog metadata: id: door-native-can-capabilities name: Door native CAN capabilities - version: 1.2.0 + version: 1.3.0 spec: domain: door capabilities: @@ -22,9 +22,9 @@ spec: risk_level: high confirmation_policy: always - name: door.set_rear_lock - description: Observational only and deprecated. Physical control is unsupported on the current CH1; legacy 0x32B is second-row-only and must not be treated as whole-vehicle locking. - execution_support: observational_only - physical_control_supported: false + description: Matrix- and head-unit-backed second-row door lock request on classic CAN ID 0x32B, sub-ID 0x0A. It controls only the rear doors and must never be represented as whole-vehicle locking; physical completion still requires matching 0x407 feedback. + execution_support: matrix_candidate_with_feedback + physical_control_supported: true target_devices: [rear_door_lock] target_scopes: [rear] parameters: @@ -32,6 +32,6 @@ spec: type: string required: true allowed_values: [lock, unlock] - description: Legacy second-row intent retained only for explicit rejection and compatibility parsing. + description: Second-row lock request; lock is raw 1 and unlock is raw 2 at start bit 14. risk_level: high - confirmation_policy: always + confirmation_policy: on_ambiguity diff --git a/config/native_can_capabilities/driver_seat.yaml b/config/native_can_capabilities/driver_seat.yaml index 506b208..452189c 100644 --- a/config/native_can_capabilities/driver_seat.yaml +++ b/config/native_can_capabilities/driver_seat.yaml @@ -3,7 +3,7 @@ kind: CapabilityCatalog metadata: id: driver-seat-native-can-capabilities name: Driver seat native CAN capabilities - version: 1.0.0 + version: 1.1.0 spec: domain: seat capabilities: @@ -108,3 +108,40 @@ spec: description: Bounded motion duration; omitted values default to 500 ms. risk_level: high confirmation_policy: on_ambiguity + + - name: seat.set_driver_position_native + description: >- + Alias for recalling one of the three physical driver/front-left seat memory + slots. The complete 0x4C1/0x15 and /0x16 payloads must have been learned from + the PAD and a fresh 0x494 must prove all four axes initialized before replay. + target_devices: [driver_seat_memory] + target_scopes: [driver, front_left] + parameters: + - name: slot + type: integer + required: true + minimum: 1 + maximum: 3 + allowed_values: [1, 2, 3] + description: Previously learned PAD memory slot. + risk_level: high + confirmation_policy: on_ambiguity + + - name: seat.recall_driver_memory_native + description: >- + Recall one of the three physical driver/front-left seat memory slots by + alternating that slot's learned 0x4C1/0x16 and /0x15 payloads for ten cycles. + 0x494 does not expose final absolute motor positions, so delivery is not + represented as proof that the seat reached its final position. + target_devices: [driver_seat_memory] + target_scopes: [driver, front_left] + parameters: + - name: slot + type: integer + required: true + minimum: 1 + maximum: 3 + allowed_values: [1, 2, 3] + description: Previously learned PAD memory slot. + risk_level: high + confirmation_policy: on_ambiguity diff --git a/docs/workflows/linux-zlg-vehicle-state.md b/docs/workflows/linux-zlg-vehicle-state.md index c4b3b35..c3249c3 100644 --- a/docs/workflows/linux-zlg-vehicle-state.md +++ b/docs/workflows/linux-zlg-vehicle-state.md @@ -5,7 +5,7 @@ `monitor-only` 桥,桥进程会拒绝所有发送请求。服务启动后由一个常驻监听线程 持续缓存最新的已验证状态,HTTP 接口不会为每次前端请求反复开关 USB 设备。 Linux 只读状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒内的新鲜反馈, -断线后会自动关闭旧桥并重试连接。监听通道只接受本文列出的 11 个标准帧 ID, +断线后会自动关闭旧桥并重试连接。监听通道只接受本文列出的 12 个标准帧 ID, 同时在 SDK 桥内保留同样的软件过滤,避免把无关总线流量转发给服务进程。 ## 硬件和总线基线 @@ -21,7 +21,8 @@ Linux 只读状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒 前端车辆概况依赖 `0x342`(档位)、`0x4EB/0x07`(车速)和 `0x407` (门锁)。主驾座椅依赖 `0x4FA`、`0x494`。收到其他帧不能替代这些已验证 -反馈。 +反馈。车身设备还会按帧独立更新:`0x388` 四窗、`0x404` 空调、`0x41A` +遮阳帘、`0x42E/0x3F3/0x3FF` 车内灯光,以及 `0x38A` 前部灯光。 ## 前端有线 / 无线选择 @@ -29,7 +30,7 @@ Linux 只读状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒 - 有线控制:状态和控制都走后端 ZLG CH1。 - 无线控制:状态订阅 `n7/n7-headunit/can/state`,控制发布到 - `n7/can_control`;默认 Broker 为 `127.0.0.1:1883`。MQTT CAN0 专用于 + `n7/n7-headunit/can/command`;默认 Broker 为 `127.0.0.1:1883`。MQTT CAN0 专用于 副驾座椅,MQTT CAN1 专用于除副驾座椅外的车辆状态和控制。 选择保存在当前浏览器,并随车辆状态请求和控制请求一起传给后端。切换传输时 @@ -37,7 +38,8 @@ Linux 只读状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒 共用同一套编译范围检查、P 挡/零车速预检、反馈目标匹配和硬件批准令牌;MQTT 发布成功只证明 Broker 接收了消息,不能替代 ECU 状态反馈。 -MQTT 主题只承载原始 CAN 帧,不接受已经解析的车辆状态 JSON。规范帧格式为: +MQTT 状态主题只承载原始 CAN 帧,不接受已经解析的车辆状态 JSON。接收侧继续 +兼容下面的规范单帧格式: ```json { @@ -54,11 +56,36 @@ MQTT 主题只承载原始 CAN 帧,不接受已经解析的车辆状态 JSON } ``` +车机实际使用的 `n7-can-signals/v1` 状态包由顶层 `signals` 承载同样的原始帧。 `n7/n7-headunit/can/state` 可以发送单帧对象,也可以发送 `{"frames":[...]}` 批次。接收侧兼容 `can_id`、`canId`、`frame_id` 和 `dataHex` 等常见字段名,但仍强制 CAN0/CAN1、11 位 ID、1–64 字节长度和 -逐通道反馈 ID 白名单。`n7/can_control` 始终由后端输出上述规范单帧格式, -其中 `channel` 保留编译计划选择的通道。 +逐通道反馈 ID 白名单。 + +自然语言指令仍先由后端编译、检查白名单和车辆安全条件。每个已批准的 CAN 帧 +再转换为车机 App `CanCommand` 格式并发布到 +`n7/n7-headunit/can/command`: + +```json +{ + "requestId": "cognitive-agent-0e77879b83fd47a9bf12efb3d20dfae7", + "channel": "can1", + "canId": "0x431", + "frameType": "fd", + "brs": true, + "extended": false, + "remote": false, + "data": "10 00 00 00 00 00 00 00", + "repeat": 1, + "intervalMs": 0, + "responseTimeoutMs": 1500 +} +``` + +每次发布生成不同的 `requestId`。MQTT 外层固定 `repeat=1`,原有执行器继续负责 +矩阵要求的重复次数、周期和动作后的安全停止,不会在车机端再次叠加重复发送。 +CAN0 输出 `classic` 且禁用 BRS;CAN1 按编译结果输出 Classic CAN 或合法长度的 +FD+BRS。车机按通道、控制 ID 和帧类型推断预期反馈 ID。 无线通道边界为: @@ -82,7 +109,9 @@ MQTT 主题只承载原始 CAN 帧,不接受已经解析的车辆状态 JSON - CAN1 只以 `/home/orin/project511/project-code/canfd/CAN矩阵表_filled.xlsx` 为准。 例如 `0x42E` 颜色值只有 `0x01–0x1F` 有效,保留值不会显示为 - 真实颜色。 + 真实颜色。`0x388` 的四窗关好位按矩阵解析;byte 3–6 的四窗百分比另有 + `project-code/canfd/window_native.txt` 实车变化轨迹和车端原始帧证据, + 因此只有数值落在 `0–100` 时才显示为开度。 MQTT 消息自带的 `can0Seat.parsed` 和 `can1Vehicle.parsed` 只可用于车端 诊断,后端不使用它们更新状态或通过安全门禁。 @@ -159,9 +188,11 @@ make dev curl -s http://127.0.0.1:8000/api/runtime ``` -车辆状态接口应返回 `source=physical_can_ch1`、实际收到的 -`observed_frame_ids`,以及缺失反馈列表。发送成功、CAN ACK 和状态反馈是不同 -证据层级;只有接口实际收到并解码目标反馈才会更新前端。 +车辆状态接口在有线模式应返回 `source=physical_can_ch1`,在无线模式应返回 +`source=wireless_mqtt_ch1`,并包含实际收到的 `observed_frame_ids` 和缺失反馈 +列表。前端“整车设备状态”会把这两个来源都作为权威实时状态,并明确显示 +“有线 CAN”或“无线 CAN”。发送成功、CAN ACK 和状态反馈是不同证据层级; +只有接口实际收到并解码目标反馈才会更新前端。 ## SDK 自检 diff --git a/domains/seat/schemas/function-parameters.schema.json b/domains/seat/schemas/function-parameters.schema.json index 6a3f914..031234f 100755 --- a/domains/seat/schemas/function-parameters.schema.json +++ b/domains/seat/schemas/function-parameters.schema.json @@ -121,6 +121,26 @@ } } }, + { + "type": "object", + "properties": { + "capability": { + "type": "string", + "enum": [ + "seat.set_driver_position_native", + "seat.recall_driver_memory_native" + ] + }, + "parameters": { + "type": "object", + "additionalProperties": false, + "required": ["slot"], + "properties": { + "slot": {"type": "integer", "minimum": 1, "maximum": 3} + } + } + } + }, { "type": "object", "properties": { diff --git a/domains/seat/src/seat_domain/intent.py b/domains/seat/src/seat_domain/intent.py index 4fa4b43..f75db38 100755 --- a/domains/seat/src/seat_domain/intent.py +++ b/domains/seat/src/seat_domain/intent.py @@ -115,7 +115,9 @@ LEVELS = { } MOTION_AXIS_ALIASES = { "longitudinal": ("水平", "前后", "纵向", "滑轨"), - "cushion_tilt": ("坐垫倾角", "坐盆", "坐垫"), + # Internal capability name is retained for compatibility. On the + # seat-ota CAN0 firmware this board axis is the physical seat-height axis. + "cushion_tilt": ("座椅高度", "高度", "坐垫倾角", "坐盆", "坐垫"), "backrest_recline": ("靠背", "椅背"), "legrest_height": ("腿部支撑", "小腿托", "腿托", "脚托"), } @@ -161,7 +163,7 @@ TIMED_AXIS_MS_PER_PCT = { } MOTION_AXIS_LABELS = { "longitudinal": "水平轴", - "cushion_tilt": "坐垫轴", + "cushion_tilt": "座椅高度轴", "backrest_recline": "靠背轴", "legrest_height": "腿托轴", } @@ -1057,7 +1059,7 @@ def parse_local_intent( endpoint_actions["longitudinal"] = 0 elif re.search(r"最后面|最后|向后移到底|移到最后", clause): endpoint_actions["longitudinal"] = 100 - cushion_alias = r"(?:坐垫倾角|坐盆|坐垫)" + cushion_alias = r"(?:座椅高度|高度|坐垫倾角|坐盆|坐垫)" if re.search( cushion_alias + r"(?:抬到最上方|调到最上方|移到最上方|最高|抬到头|抬到底)", clause, @@ -1250,7 +1252,7 @@ def parse_local_intent( } axis_labels = { "longitudinal": "水平", - "cushion_tilt": "坐盆", + "cushion_tilt": "高度", "backrest_recline": "靠背", "legrest_height": "腿托", } diff --git a/domains/seat/tests/integration/test_seat_service.py b/domains/seat/tests/integration/test_seat_service.py index cf2c35d..8e43229 100755 --- a/domains/seat/tests/integration/test_seat_service.py +++ b/domains/seat/tests/integration/test_seat_service.py @@ -177,7 +177,7 @@ class SeatServiceIntegrationTests(unittest.TestCase): route = next(event for event in result["trace_events"] if event["stage"] == "domain_route") self.assertEqual(route["details"]["domains"], ["climate"]) - def test_openai_unscoped_passenger_target_is_rewritten_to_driver_ch1(self) -> None: + def test_local_rules_precede_openai_for_unscoped_driver_console_command(self) -> None: model_output = { "detected_domains": ["seat"], "requests": [ @@ -202,29 +202,24 @@ class SeatServiceIntegrationTests(unittest.TestCase): "关闭座椅通风", "openai", context ) - self.assertEqual(parsed["decision_summary"], ["model-owned-plan"]) - self.assertEqual(parsed["model_requests"], model_output["requests"]) - self.assertEqual(parsed["actions"], []) + self.assertTrue( + any("默认选择主驾座椅" in item for item in parsed["decision_summary"]) + ) + self.assertEqual(len(parsed["model_requests"]), 1) self.assertEqual( - parsed["deferred_requests"], - [ - { - "domain": "seat", - "capability": "seat.set_driver_ventilation_native", - "target_scope": "driver", - "desired_change": "deactivate", - "requested_value": "off", - "parameters": {"state": "off"}, - "reason": "model-owned-plan", - "execution_backend": "native_can", - } - ], + parsed["model_requests"][0]["capability"], + "seat.set_driver_ventilation_native", ) - self.assertEqual(provider, f"openai:{self.service.openai.model}") + self.assertEqual(parsed["actions"], []) + self.assertEqual(len(parsed["deferred_requests"]), 1) + deferred = parsed["deferred_requests"][0] + self.assertEqual(deferred["capability"], "seat.set_driver_ventilation_native") + self.assertEqual(deferred["target_scope"], "driver") + self.assertEqual(deferred["parameters"], {"state": "off"}) + self.assertEqual(deferred["execution_backend"], "native_can") + self.assertEqual(provider, "local_rules") self.assertIsNone(notice) - interpret.assert_called_once_with( - "关闭座椅通风", context, self.service.capability_context - ) + interpret.assert_not_called() def test_explicit_gpt_failure_falls_back_to_local_rules(self) -> None: self.service.openai.api_key = "test-key" @@ -235,7 +230,7 @@ class SeatServiceIntegrationTests(unittest.TestCase): side_effect=OpenAIIntentError("invalid_api_key"), ): result = self.service.process( - "打开副驾座椅通风", + "打开副驾座椅通风,同时播放音乐", "openai", "conv-explicit-gpt-fallback", ) @@ -250,6 +245,75 @@ class SeatServiceIntegrationTests(unittest.TestCase): any("已降级到本地规则解析" in item for item in result["decision_summary"]) ) + def test_local_first_calls_model_with_only_uncovered_clauses(self) -> None: + self.service.openai.api_key = "test-key" + model_output = { + "detected_domains": ["ambient_light"], + "requests": [ + { + "domain": "ambient_light", + "capability": "ambient_light.set_power", + "target_scope": "vehicle", + "operation": "set", + "parameters": [{"name": "state", "value": "on"}], + "reason": "播放场景需要氛围灯。", + } + ], + "ambiguities": [], + "decision_summary": ["仅补充本地规则未覆盖的播放场景。"], + "confidence": 0.9, + } + context = self.service.conversations.snapshot("conv-clause-fallback") + with patch.object( + self.service.openai, "interpret", return_value=model_output + ) as interpret: + parsed, provider, notice = self.service._interpret( + "空调温度设置为25度,播放音乐", + "openai", + context, + ) + + interpret.assert_called_once_with( + "播放音乐", context, self.service.capability_context + ) + self.assertEqual( + provider, f"local_rules+openai:{self.service.openai.model}" + ) + self.assertIn("仅未覆盖", str(notice)) + self.assertEqual( + [item["capability"] for item in parsed["native_can_actions"]], + ["climate.set_temperature"], + ) + self.assertEqual( + [item["capability"] for item in parsed["domain_actions"]], + ["ambient_light.set_power"], + ) + + def test_local_first_compiles_memory_temperature_and_fan_without_model(self) -> None: + self.service.openai.api_key = "test-key" + with patch.object(self.service.openai, "interpret") as interpret: + result = self.service.process( + "主驾座椅调整到记忆位置3,空调温度设置为25度,风速设置为1档", + "openai", + "conv-local-memory-climate", + ) + + interpret.assert_not_called() + self.assertEqual(result["status"], "succeeded") + self.assertEqual(result["provider_used"], "local_rules") + self.assertEqual( + set(result["function_configs"]), {"native_seat", "native_climate"} + ) + commands = [ + command["signals"]["debug_command"] + for plan in result["command_plans"].values() + for command in plan["spec"]["commands"] + ] + self.assertEqual( + commands, + ["seat driver memory recall 3", "ac temp driver 25", "ac fan 1"], + ) + def test_model_climate_request_completes_domain_loop(self) -> None: model_output = { "detected_domains": ["climate"], @@ -316,7 +380,7 @@ class SeatServiceIntegrationTests(unittest.TestCase): with patch.object(self.service.openai, "interpret", return_value=model_output): result = self.service.process( - "副驾有点冷、有点闷", + "打开副驾座椅加热,播放音乐并把车内风量调舒适", "openai", "conv-model-partial", ) @@ -328,6 +392,10 @@ class SeatServiceIntegrationTests(unittest.TestCase): {"state": "on", "level": 1}, ) self.assertEqual(result["domain_state"]["climate"]["fan_level"], 2) + self.assertEqual( + result["provider_used"], + f"local_rules+openai:{self.service.openai.model}", + ) self.assertEqual(set(result["function_configs"]), {"seat", "climate"}) self.assertEqual(result["scene_config"]["kind"], "SceneConfig") diff --git a/src/cognitive_agent/application/conversation_state.py b/src/cognitive_agent/application/conversation_state.py index 81d1e37..d726fc8 100755 --- a/src/cognitive_agent/application/conversation_state.py +++ b/src/cognitive_agent/application/conversation_state.py @@ -89,6 +89,10 @@ def _new_state( "absolute_position_available": target == "passenger", "initialized": {}, }, + "memory_recall": { + "slot": None, + "state": "idle", + }, } for target in TARGETS }, @@ -145,6 +149,15 @@ def _new_state( }, "outlet": {"targets": {}}, "interior_light": {"power": "off"}, + "front_light": { + "position_light": {"state": "unknown"}, + "low_beam": {"state": "unknown"}, + "high_beam": {"state": "unknown"}, + "fog_light": {"state": "unknown"}, + "brake_light": {"state": "unknown"}, + "reverse_light": {"state": "unknown"}, + "all_weather_light": {"state": "unknown"}, + }, }, "presets": list(presets or []), "recent_turns": [], @@ -278,17 +291,21 @@ class ConversationStateStore: conversation_id: str, feedback_states: list[dict[str, Any]], ) -> dict[str, Any]: - """Merge decoded, authoritative CH1 feedback into durable UI state.""" + """Merge decoded, authoritative wired or wireless CH1 feedback.""" with self._lock: state = self._state(conversation_id) synchronized = False + synchronized_sources: set[str] = set() for feedback in feedback_states: if not isinstance(feedback, dict): continue verification = feedback.get("verification") + feedback_source = str(feedback.get("source", "")) + wireless_mqtt = feedback_source == "wireless_mqtt_ch1" if ( - feedback.get("source") != "physical_can_ch1" + feedback_source + not in {"physical_can_ch1", "wireless_mqtt_ch1"} or feedback.get("channel") != 1 or feedback.get("valid") is not True or not isinstance(verification, dict) @@ -306,6 +323,10 @@ class ConversationStateStore: or verification.get("frame_id") != feedback.get("frame_id") or verification.get("frame_id") != feedback.get("feedback_frame_id") + or ( + wireless_mqtt + and verification.get("transport") != "wireless_mqtt" + ) ): continue @@ -319,14 +340,15 @@ class ConversationStateStore: for feature, feature_state in target_patch.items(): stored = state["devices"][target_name].get(feature) if isinstance(feature_state, dict) and isinstance(stored, dict): - stored["feedback_source"] = "physical_can_ch1" + stored["feedback_source"] = feedback_source synchronized = True + synchronized_sources.add(feedback_source) vehicle_safety = feedback.get("vehicle_safety") if isinstance(vehicle_safety, dict) and vehicle_safety: vehicle_status = state["domain_devices"]["vehicle_status"] _merge_mapping(vehicle_status, vehicle_safety) - vehicle_status["feedback_source"] = "physical_can_ch1" + vehicle_status["feedback_source"] = feedback_source speed_kmh = vehicle_status.get("speed_kmh") gear = vehicle_status.get("gear") vehicle_status["stationary"] = ( @@ -337,6 +359,7 @@ class ConversationStateStore: else None ) synchronized = True + synchronized_sources.add(feedback_source) domain_state = feedback.get("domain_state") if not isinstance(domain_state, dict): @@ -348,6 +371,7 @@ class ConversationStateStore: "door", "window", "interior_light", + "front_light", ): domain_patch = domain_state.get(domain) target_domain = state["domain_devices"].get(domain) @@ -358,27 +382,38 @@ class ConversationStateStore: ): continue _merge_mapping(target_domain, domain_patch) - target_domain["feedback_source"] = "physical_can_ch1" + target_domain["feedback_source"] = feedback_source if domain == "ambient_light": level = domain_patch.get("front_brightness_level") if isinstance(level, int) and not isinstance(level, bool): target_domain["brightness_pct"] = max( - 0, min(100, round(level * 100 / 3)) + 0, min(100, round(level * 100 / 5)) ) if domain == "door": for name in domain_patch.get("locks", {}): lock = target_domain.get("locks", {}).get(name) if isinstance(lock, dict): - lock["feedback_source"] = "physical_can_ch1" + lock["feedback_source"] = feedback_source if domain == "window": for name in domain_patch.get("positions", {}): position = target_domain.get("positions", {}).get(name) - if isinstance(position, dict): - position["feedback_source"] = "physical_can_ch1" + position_patch = domain_patch["positions"].get(name) + if isinstance(position, dict) and isinstance(position_patch, dict): + # Remove virtual absolute-position defaults when + # a verified window patch carries only the + # matrix closed/not-closed flag and no percent. + if "position_pct" not in position_patch: + position.pop("position_pct", None) + position["feedback_source"] = feedback_source synchronized = True + synchronized_sources.add(feedback_source) if synchronized: - state["physical_ch1_state_updated_at"] = _now() + state["ch1_state_updated_at"] = _now() + if "physical_can_ch1" in synchronized_sources: + state["physical_ch1_state_updated_at"] = _now() + if "wireless_mqtt_ch1" in synchronized_sources: + state["wireless_mqtt_ch1_state_updated_at"] = _now() state["updated_at"] = _now() return deepcopy(state) @@ -484,7 +519,10 @@ class ConversationStateStore: parameters = action["parameters"] feedback_source = str(action.get("feedback_source", "virtual")) - if domain == "seat" and capability.startswith("seat.set_driver_"): + if domain == "seat" and ( + capability.startswith("seat.set_driver_") + or capability == "seat.recall_driver_memory_native" + ): driver = state["devices"]["driver"] if capability == "seat.set_driver_ventilation_native": value = str(parameters["state"]) @@ -514,6 +552,16 @@ class ConversationStateStore: "motion_state": "stopped", "feedback_source": feedback_source, } + elif capability in { + "seat.set_driver_position_native", + "seat.recall_driver_memory_native", + }: + driver["memory_recall"] = { + "slot": int(parameters["slot"]), + "state": "requested", + "absolute_position_confirmed": False, + "feedback_source": feedback_source, + } return device = state["domain_devices"][domain] diff --git a/src/cognitive_agent/application/mode_profile.py b/src/cognitive_agent/application/mode_profile.py index 53427f3..e3b34a2 100644 --- a/src/cognitive_agent/application/mode_profile.py +++ b/src/cognitive_agent/application/mode_profile.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import json import re from dataclasses import dataclass from pathlib import Path @@ -13,6 +14,7 @@ import yaml _WHITESPACE = re.compile(r"\s+") _NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+") +_MODE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") def _normalize_match_text(value: str) -> str: @@ -28,6 +30,37 @@ def _clean_name(value: str) -> str: return cleaned +def _clean_mode_id(value: str) -> str: + cleaned = str(value).strip() + if not _MODE_ID.fullmatch(cleaned): + raise ValueError( + "mode id must start with a lowercase letter or number and contain " + "only lowercase letters, numbers, underscores, or hyphens" + ) + return cleaned + + +def _clean_instruction(value: str) -> str: + cleaned = str(value).strip() + if len(cleaned) > 1000: + raise ValueError("mode instruction must be at most 1000 characters") + return cleaned + + +def _clean_command_flow(value: Any) -> dict[str, Any] | None: + if value is None: + return None + if not isinstance(value, dict): + raise ValueError("mode command_flow must be an object") + try: + encoded = json.dumps(value, ensure_ascii=False, sort_keys=True) + except (TypeError, ValueError) as exc: + raise ValueError("mode command_flow must be JSON serializable") from exc + if len(encoded.encode("utf-8")) > 1024 * 1024: + raise ValueError("mode command_flow must be at most 1 MiB") + return json.loads(encoded) + + def _mode_aliases(name: str, aliases: list[str] | None = None) -> list[str]: values = [name, *(aliases or [])] if name.endswith("模式") and len(name) > 2: @@ -304,6 +337,8 @@ class ModeProfile: vehicle_ids: tuple[str, ...] = () timeline: ModeTimeline | None = None user_defined: bool = False + instruction: str = "" + command_flow: dict[str, Any] | None = None @classmethod def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile": @@ -338,6 +373,8 @@ class ModeProfile: else None ), user_defined=_is_user_profile_path(path, profile_root), + instruction=_clean_instruction(document.get("instruction", "")), + command_flow=_clean_command_flow(document.get("command_flow")), ) def public_dict(self) -> dict[str, Any]: @@ -356,6 +393,8 @@ class ModeProfile: "behavior": "cyclic" if self.timeline is not None else "static", "origin": "user" if self.user_defined else "preset", "deletable": self.user_defined, + "instruction": self.instruction, + "command_flow": self.command_flow, } if self.timeline is not None: result["timeline"] = self.timeline.public_dict() @@ -397,10 +436,19 @@ class ModeProfileRegistry: def public_modes(self, vehicle_id: str | None = None) -> list[dict[str, Any]]: profiles = self._profiles_for_vehicle(vehicle_id) profiles.sort(key=lambda profile: 0 if profile.user_defined else 1) - return [ - profile.public_dict() - for profile in profiles - ] + preset_ids = { + profile.id + for profile in self._visible_candidates_for_vehicle(vehicle_id) + if not profile.user_defined + } + public = [] + for profile in profiles: + item = profile.public_dict() + if profile.user_defined and profile.id in preset_ids: + item["origin"] = "customized" + item["preset_override"] = True + public.append(item) + return public def match_text( self, @@ -442,6 +490,12 @@ class ModeProfileRegistry: mode_id: str | None = None, aliases: list[str] | None = None, vehicle_id: str | None = None, + *, + description: str | None = None, + icon: str = "bookmark", + instruction: str = "", + command_flow: dict[str, Any] | None = None, + timeline: ModeTimeline | None = None, ) -> ModeProfile: clean_name = _clean_name(name) canonical_operations = [ @@ -452,7 +506,9 @@ class ModeProfileRegistry: ] if not canonical_operations: raise ValueError("mode profile must contain at least one operation") - profile_id = mode_id or _slugify_mode_id(clean_name) + profile_id = _clean_mode_id(mode_id or _slugify_mode_id(clean_name)) + clean_instruction = _clean_instruction(instruction) + clean_command_flow = _clean_command_flow(command_flow) scope = ( {"vehicle_ids": [vehicle_id]} if vehicle_id @@ -462,13 +518,19 @@ class ModeProfileRegistry: "id": profile_id, "name": clean_name, "aliases": _mode_aliases(clean_name, aliases), - "description": "用户固化的长期模式配置。", - "icon": "bookmark", + "description": str(description or "用户固化的长期模式配置。")[:500], + "icon": str(icon or "bookmark")[:64], "scope": scope, "operations": [ operation.to_yaml() for operation in canonical_operations ], } + if clean_instruction: + document["instruction"] = clean_instruction + if clean_command_flow is not None: + document["command_flow"] = clean_command_flow + if timeline is not None: + document["timeline"] = timeline.to_yaml() path = ( self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml" if vehicle_id @@ -653,6 +715,10 @@ def _mode_profile_document( } if profile.timeline is not None: document["timeline"] = profile.timeline.to_yaml() + if profile.instruction: + document["instruction"] = profile.instruction + if profile.command_flow is not None: + document["command_flow"] = profile.command_flow return document diff --git a/src/cognitive_agent/application/mqtt_ota_seat_executor.py b/src/cognitive_agent/application/mqtt_ota_seat_executor.py index 1db6982..8ef220d 100644 --- a/src/cognitive_agent/application/mqtt_ota_seat_executor.py +++ b/src/cognitive_agent/application/mqtt_ota_seat_executor.py @@ -23,6 +23,9 @@ OTA_SEAT_AXIS_ALL = 0xFF OTA_RESPONSE_TIMEOUT_MS = 1_500 OTA_HEARTBEAT_INTERVAL_MS = 500 OTA_DYNAMIC_DURATION_SECONDS = 300 +# The seat domain's semantic mode IDs predate the seat-ota firmware enum. +# Keep the public meaning stable while sending the firmware selector exactly. +OTA_DYNAMIC_SELECTOR_BY_SEMANTIC_MODE = {1: 2, 2: 1} OTA_STATUS_NAMES = { 0x00: "OK", 0x01: "BAD_MAGIC", @@ -68,6 +71,28 @@ class MqttOtaSeatExecutor: def read_physical_state(self) -> dict[str, Any]: return self.status_executor.read_physical_state() + @staticmethod + def _operation_debug_command(operation: dict[str, Any]) -> str: + capability = str(operation["capability"]) + parameters = operation.get("parameters", {}) + axes = { + "seat.set_longitudinal_position": "longitudinal", + "seat.set_cushion_tilt": "height", + "seat.set_backrest_recline": "backrest", + "seat.set_legrest_height": "legrest", + } + if capability in axes: + return ( + f"seat passenger {axes[capability]} position " + f"{parameters['position_pct']}" + ) + if capability == "seat.start_dynamic_mode": + mode = "rocking-horse" if int(parameters["mode_id"]) == 1 else "cradle" + return f"seat passenger dynamic start {mode}" + if capability == "seat.stop_dynamic_mode": + return "seat passenger dynamic stop" + return capability + @staticmethod def _axis_state(frame: CanFrame) -> dict[str, Any] | None: if frame.arbitration_id != 0x302 or len(frame.data) != 8: @@ -290,7 +315,11 @@ class MqttOtaSeatExecutor: stopping = operation["capability"] == "seat.stop_dynamic_mode" mode_id = 0 if stopping else int(operation["parameters"]["mode_id"]) opcode = OTA_OPCODE_SEAT_STOP if stopping else OTA_OPCODE_SEAT_DYNAMIC_START - selector = OTA_SEAT_AXIS_ALL if stopping else mode_id + selector = ( + OTA_SEAT_AXIS_ALL + if stopping + else OTA_DYNAMIC_SELECTOR_BY_SEMANTIC_MODE[mode_id] + ) duration = 0 if stopping else OTA_DYNAMIC_DURATION_SECONDS command_frame, response_frame, job_state = self._send_control( bus, @@ -320,6 +349,7 @@ class MqttOtaSeatExecutor: ), "telemetry": { "dynamic_mode_id": mode_id, + "ota_dynamic_selector": None if stopping else selector, "dynamic_mode_name": ( "" if stopping else "木马模式" if mode_id == 1 else "动态摇篮模式" ), @@ -363,6 +393,17 @@ class MqttOtaSeatExecutor: operations[item] for item in command["operation_ref"].split(",") ] + publication_start = len( + getattr(bus, "published_messages", ()) + ) + set_debug_command = getattr(bus, "set_debug_command", None) + if callable(set_debug_command): + set_debug_command( + "; ".join( + self._operation_debug_command(item) + for item in referenced + ) + ) if command["message"] != "T2G_Seat_Move_Command_300": raise HardwareExecutionError( "seat-ota firmware does not provide wireless thermal control; " @@ -371,14 +412,27 @@ class MqttOtaSeatExecutor: opcode = int(command["signals"]["Opcode"]) if opcode in {0x0E, 0x0F}: motion_armed = opcode == 0x0E - results.extend( - self._execute_dynamic(bus, command, referenced) + command_results = self._execute_dynamic( + bus, command, referenced ) motion_armed = False else: motion_armed = True - results.extend(self._execute_move(bus, command, referenced)) + command_results = self._execute_move( + bus, command, referenced + ) motion_armed = False + mqtt_messages = list( + getattr(bus, "published_messages", ())[ + publication_start: + ] + ) + if mqtt_messages: + for item in command_results: + item.setdefault("telemetry", {})[ + "mqtt_messages" + ] = mqtt_messages + results.extend(command_results) except Exception as exc: # noqa: BLE001 - normalize protocol failures if bus is not None and motion_armed: self._best_effort_stop(bus) diff --git a/src/cognitive_agent/application/native_can_feedback.py b/src/cognitive_agent/application/native_can_feedback.py index 817eecf..9f2758c 100644 --- a/src/cognitive_agent/application/native_can_feedback.py +++ b/src/cognitive_agent/application/native_can_feedback.py @@ -183,6 +183,10 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]: names = ("front_left", "rear_left", "front_right", "rear_right") positions = { name: { + # The filled matrix defines the four closed flags in byte 7. + # Vehicle traces in project-code/canfd/window_native.txt and + # the head-unit envelope both independently show bytes 3..6 + # tracking the four 0..100 opening percentages. "position_pct": int(data[2 + index]) if data[2 + index] <= 100 else None, "closed": {1: True, 2: False}.get(_bits(data, 48 + index * 2, 2)), } @@ -211,6 +215,7 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]: "climate": { "power": power, "ac": power, + "front_defrost": _state(_bits(data, 14, 2), on=2, off=1), "control_mode": control, "cycle_mode": cycle, "fan_level": _bits(data, 20, 4), @@ -223,7 +228,12 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]: return decoded if frame.arbitration_id == 0x4FA and len(data) >= 8: - thermal = {1: ("off", 0), 2: ("on", 1), 3: ("on", 3)} + thermal = { + 1: ("off", 0), + 2: ("on", 1), + 3: ("on", 2), + 4: ("on", 3), + } vent_state, vent_level = thermal.get(_bits(data, 0, 3), ("unknown", 0)) heat_state, heat_level = thermal.get(_bits(data, 3, 3), ("unknown", 0)) massage_mode_raw = _bits(data, 40, 4) @@ -241,6 +251,8 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]: 5: "slack", 6: "relax", }.get(massage_mode_raw, "unknown"), + # Strength is retained from the repository's verified HIL + # traces; the filled matrix only lists the adjacent mode. "strength": {8: "off", 9: "weak", 10: "medium", 11: "strong"}.get( massage_strength_raw, "unknown" ), @@ -249,6 +261,24 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]: } return decoded + if frame.arbitration_id == 0x38A and len(data) >= 8: + decoded["domain_state"] = { + "front_light": { + "position_light": {"state": _state(_bits(data, 0, 2))}, + "low_beam": {"state": _state(_bits(data, 2, 2))}, + "high_beam": {"state": _state(_bits(data, 4, 2))}, + "fog_light": {"state": _state(_bits(data, 8, 2))}, + "brake_light": { + "state": {1: "on", 2: "flashing", 3: "off"}.get( + _bits(data, 10, 2), "unknown" + ) + }, + "reverse_light": {"state": _state(_bits(data, 12, 2))}, + "all_weather_light": {"state": _state(_bits(data, 14, 2))}, + } + } + return decoded + if frame.arbitration_id == 0x494 and len(data) >= 8: decoded["actuator_state"] = { "driver": { @@ -375,6 +405,8 @@ def feedback_matches(expected: str, frame: CanFrame | None) -> bool | None: return climate["power"] == parts[1] if family == "climate_control": return climate["control_mode"] == {"1": "manual", "2": "auto"}.get(parts[1]) + if family == "climate_cycle": + return climate["cycle_mode"] == parts[1] if family == "climate_mode": expected_mode = { "1": "face", @@ -534,6 +566,11 @@ def feedback_matches(expected: str, frame: CanFrame | None) -> bool | None: ) return initialized.get(parts[1]) is True + if family == "driver_seat_memory_status": + # A post-command 0x494 proves only that the seat ECU is still reporting + # initialized axes; the frame has no absolute target-position signal. + return None + return None diff --git a/src/cognitive_agent/application/native_can_intent.py b/src/cognitive_agent/application/native_can_intent.py index ee23407..2706d38 100644 --- a/src/cognitive_agent/application/native_can_intent.py +++ b/src/cognitive_agent/application/native_can_intent.py @@ -70,6 +70,8 @@ CH1_DEFAULT_TARGETS = { "seat.set_driver_heating_native": "driver", "seat.set_driver_massage_native": "driver", "seat.set_driver_axis_native": "driver", + "seat.set_driver_position_native": "driver", + "seat.recall_driver_memory_native": "driver", "roof.set_shade_motion": "vehicle", "roof.set_shade_position": "vehicle", "interior_light.set_indoor_power": "vehicle", @@ -159,6 +161,9 @@ def _action( def _parse_exact_command(text: str) -> list[dict[str, Any]]: command = " ".join(text.strip().lower().split()) + match = re.fullmatch(r"door rear (lock|unlock)", command) + if match: + return [_action("door", "door.set_rear_lock", "rear", {"state": match[1]}, "用户输入了受信任的后门门锁命令。")] match = re.fullmatch(r"door vehicle (lock|unlock)", command) if match: return [_action("door", "door.set_vehicle_lock", "vehicle", {"state": match[1]}, "用户输入了受信任的整车门锁命令。")] @@ -295,6 +300,29 @@ def _parse_exact_command(text: str) -> list[dict[str, Any]]: "用户输入了受信任的 CH1 主驾座椅限时调节命令。", )] + match = re.fullmatch(r"seat (driver|front-left) position ([1-3])", command) + if match: + target = "front_left" if match[1] == "front-left" else "driver" + return [_action( + "seat", + "seat.set_driver_position_native", + target, + {"slot": int(match[2])}, + "用户输入了受信任的 CH1 主驾座椅记忆位置命令。", + )] + match = re.fullmatch( + r"seat (driver|front-left) memory recall ([1-3])", command + ) + if match: + target = "front_left" if match[1] == "front-left" else "driver" + return [_action( + "seat", + "seat.recall_driver_memory_native", + target, + {"slot": int(match[2])}, + "用户输入了受信任的 CH1 主驾座椅记忆唤醒命令。", + )] + match = re.fullmatch(r"light indoor (on|off)", command) if match: return [_action("interior_light", "interior_light.set_indoor_power", "vehicle", {"state": match[1]}, "用户输入了受信任的室内阅读灯总开关命令。")] @@ -352,6 +380,24 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]: default_reason = "用户未指定座椅目标,按驾驶台 CH1 安全默认选择主驾座椅。" actions: list[dict[str, Any]] = [] + memory = re.search( + r"(?:记忆(?:位置|位)?|记忆座椅(?:位置)?|座椅记忆(?:位置)?|位置记忆)" + r"(?:切换到|切换为|调整到|调到|唤醒|调用|恢复到|恢复为|设为|设置为)?" + r"([1-3])(?:号|档|位)?", + compact, + ) + if memory is not None: + actions.append(_action( + "seat", + "seat.recall_driver_memory_native", + "driver", + {"slot": int(memory[1])}, + ( + "用户明确指定了 CH1 主驾座椅记忆位置。" + if explicit_driver + else default_reason + ), + )) thermal_features = ( ("通风", "seat.set_driver_ventilation_native"), ("加热", "seat.set_driver_heating_native"), @@ -505,7 +551,11 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]: compact = re.sub(r"\s+", "", text) actions: list[dict[str, Any]] = _parse_chinese_driver_seat_commands(compact) - if "空调" in compact or re.search(r"(?:AC|A/C)", text, re.IGNORECASE): + if ( + "空调" in compact + or re.search(r"(?:AC|A/C)", text, re.IGNORECASE) + or re.search(r"温度|风量|风速|内循环|外循环|循环模式", compact) + ): if re.search(r"(?:打开|开启|启动)空调", compact): actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "on"}, "用户要求打开空调。")) elif re.search(r"(?:关闭|关掉|关上)空调", compact): @@ -533,6 +583,11 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]: elif re.search(r"手动(?:控制|模式)", compact): actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "manual"}, "用户指定空调手动控制。")) + if re.search(r"内循环", compact): + actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "internal"}, "用户指定空调内循环。")) + elif re.search(r"外循环", compact): + actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。")) + outlet_modes = [ ("吹面吹脚除霜", "face_feet_defrost"), ("吹面除霜", "face_defrost"), @@ -619,9 +674,26 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]: for target in targets ) - vehicle_lock_request = bool( + rear_lock_request = bool( + re.search( + r"(?:后门|后排车门|后排门锁|二排车门|二排门锁)", compact + ) + ) + if rear_lock_request: + state = ( + "unlock" + if re.search(r"解锁|开锁", compact) + else "lock" + if re.search(r"上锁|落锁|锁上", compact) + else None + ) + if state: + actions.append(_action("door", "door.set_rear_lock", "rear", {"state": state}, "用户指定了后门门锁状态。")) + + vehicle_lock_request = not rear_lock_request and bool( re.search( r"整车(?:门锁|车门)|全车(?:门锁|车门)|(?:所有|全部)(?:车门|门锁)|" + r"(?:车门|门锁)(?:上锁|落锁|锁上|解锁|开锁)|" r"(?:锁车|整车解锁|全车解锁|解锁车辆)", compact, ) @@ -859,7 +931,11 @@ def merge_native_can_intent( item for item in parsed.get(key, []) if not is_claimed(item) ] - clauses = [part for part in re.split(r"[,,。;;!!??]+", utterance) if part.strip()] + clauses = [ + part + for part in re.split(r"[,,。;;::!!??]+", utterance) + if part.strip() + ] exact_clause_actions = [ parse_exact_native_can_command(clause) for clause in clauses ] @@ -896,11 +972,14 @@ def merge_native_can_intent( "seat.set_driver_heating_native", "seat.set_driver_massage_native", "seat.set_driver_axis_native", + "seat.set_driver_position_native", + "seat.recall_driver_memory_native", } ] implicit_driver_seat = _implicit_driver_seat_request(utterance) has_default_driver_seat_action = any( str(action.get("capability", "")).startswith("seat.set_driver_") + or action.get("capability") == "seat.recall_driver_memory_native" for action in actions ) if implicit_driver_seat: @@ -914,9 +993,9 @@ def merge_native_can_intent( result = dict(parsed) if fully_exact: # The trusted vocabulary covers every clause, so it is the authoritative - # execution plan. GPT is still called (and provider_used remains GPT), - # but model duplicates, contradictions, and unrelated hallucinations - # must never add physical operations to a byte-exact request. + # execution plan. Model duplicates, contradictions, and unrelated + # hallucinations must never add physical operations to a byte-exact + # request. for key in ( "actions", "domain_actions", @@ -1027,7 +1106,7 @@ def merge_native_can_intent( else {} ), } - for item in unique_actions + for item in actions ], ] result["model_requests"] = [ @@ -1049,7 +1128,7 @@ def merge_native_can_intent( else {} ), } - for item in unique_actions + for item in actions ], ] constraints = list(result.get("semantic_constraints", [])) @@ -1091,6 +1170,8 @@ def merge_native_can_intent( "seat.set_driver_heating_native", "seat.set_driver_massage_native", "seat.set_driver_axis_native", + "seat.set_driver_position_native", + "seat.recall_driver_memory_native", } for item in actions ) diff --git a/src/cognitive_agent/application/native_can_runtime.py b/src/cognitive_agent/application/native_can_runtime.py index 219ef9f..c0a9003 100644 --- a/src/cognitive_agent/application/native_can_runtime.py +++ b/src/cognitive_agent/application/native_can_runtime.py @@ -4,6 +4,7 @@ from __future__ import annotations import hashlib import json +import os import time from dataclasses import dataclass from datetime import UTC, datetime @@ -61,11 +62,13 @@ DRIVER_SEAT_CAPABILITIES = frozenset( "seat.set_driver_heating_native", "seat.set_driver_massage_native", "seat.set_driver_axis_native", + "seat.set_driver_position_native", + "seat.recall_driver_memory_native", } ) DRIVER_SEAT_TARGETS = frozenset({"driver", "front_left"}) UNSUPPORTED_CH1_DOOR_LOCK_CAPABILITIES = frozenset( - {"door.set_vehicle_lock", "door.set_rear_lock"} + {"door.set_vehicle_lock"} ) @@ -76,18 +79,14 @@ def _unsupported_ch1_door_lock_message(capability: str) -> str: "current CH1; the 0x496 smoke request caused no 0x407 vehicle-lock or " "four-door transition, so CH1 door-lock support is observational only" ) - return ( - "door.set_rear_lock is unsupported for physical execution on the current " - "CH1; 0x32B is second-row-only and is not a verified whole-vehicle lock " - "control, so CH1 door-lock support is observational only" - ) + return f"{capability} is unsupported for physical execution on the current CH1" class NativeCanRuntime: """Compile named capabilities into byte-exact CAN_DEV frames.""" compiler_version = "1.0.0" - mapping_version = "can-dev-verified-body-can-v4" + mapping_version = "can-dev-verified-body-can-v5" def __init__(self, repository_root: Path): self.root = repository_root @@ -122,6 +121,8 @@ class NativeCanRuntime: "climate.set_outlet_mode", "climate.set_temperature", "climate.set_control_mode", + "climate.set_cycle_mode", + "door.set_rear_lock", "outlet.set_position_percent", "outlet.set_calibrated_position", *DRIVER_SEAT_CAPABILITIES, @@ -185,7 +186,12 @@ class NativeCanRuntime: "timeout_ms": ( 20_000 if capability_name - in {"window.set_position", "roof.set_shade_position"} + in { + "window.set_position", + "roof.set_shade_position", + "seat.set_driver_position_native", + "seat.recall_driver_memory_native", + } else 4_000 ), "failure_policy": "abort", @@ -232,6 +238,7 @@ class NativeCanRuntime: f"0x{frame_id:03X}" for frame_id in spec.feedback_ids ), "expected_feedback": spec.expected_feedback, + "debug_command": self.operation_debug_command(operation), } if spec.special_handler: signals["special_handler"] = spec.special_handler @@ -337,8 +344,103 @@ class NativeCanRuntime: "stop_repeat_count": special["stop_repeat_count"], "stop_period_ms": special["stop_period_ms"], } + elif signals.get("special_handler") == "driver_seat_memory_recall": + special = json.loads(str(signals["special_parameters"])) + public["sequence"] = { + "frame_count": len(special["frame_sequence"]), + "period_ms": special["period_ms"], + "data_hex": list(special["frame_sequence"]), + "requires_fresh_initialized_0x494": True, + } return public + @staticmethod + def operation_debug_command(operation: dict[str, Any]) -> str: + """Return the trusted canonical command represented by one operation.""" + + capability = str(operation["capability"]) + parameters = operation.get("parameters", {}) + target = str(operation.get("target", {}).get("zone", "vehicle")).replace( + "_", "-" + ) + if capability == "window.set_motion": + return f"window {target} {parameters['motion']}" + if capability == "window.set_position": + return f"window {target} position {parameters['position_pct']}" + if capability == "climate.set_power": + return f"ac {parameters['state']}" + if capability == "climate.set_ac": + return f"ac compressor {parameters['state']}" + if capability == "climate.set_fan_level": + return f"ac fan {parameters['level']}" + if capability == "climate.set_outlet_mode": + return f"ac mode {str(parameters['mode']).replace('_', '-')}" + if capability == "climate.set_temperature": + return f"ac temp {target} {parameters['temperature_c']}" + if capability == "climate.set_control_mode": + return f"ac control {parameters['mode']}" + if capability == "climate.set_cycle_mode": + return f"ac cycle {parameters['mode']}" + if capability == "door.set_rear_lock": + return f"door rear {parameters['state']}" + if capability == "outlet.set_position_percent": + return ( + f"outlet {target} percent {parameters['horizontal_pct']} " + f"{parameters['vertical_pct']}" + ) + if capability == "outlet.set_calibrated_position": + return ( + f"outlet {target} position {parameters['horizontal_value']} " + f"{parameters['vertical_value']}" + ) + if capability in { + "seat.set_driver_heating_native", + "seat.set_driver_ventilation_native", + }: + feature = ( + "heat" + if capability == "seat.set_driver_heating_native" + else "vent" + ) + setting = "off" + if parameters["state"] == "on": + setting = {1: "high", 2: "low"}[int(parameters.get("level", 2))] + return f"seat driver {feature} {setting}" + if capability == "seat.set_driver_massage_native": + action = str(parameters["action"]) + if action == "off": + return "seat driver massage off" + if action == "strength": + strength = {1: "weak", 2: "medium", 3: "strong"}[ + int(parameters["strength"]) + ] + return f"seat driver massage strength {strength}" + return f"seat driver massage mode {parameters['mode']}" + if capability == "seat.set_driver_axis_native": + return ( + f"seat driver {parameters['axis']} {parameters['direction']} " + f"{parameters.get('duration_ms', 500)}" + ) + if capability == "seat.set_driver_position_native": + return f"seat driver position {parameters['slot']}" + if capability == "seat.recall_driver_memory_native": + return f"seat driver memory recall {parameters['slot']}" + if capability == "interior_light.set_indoor_power": + return f"light indoor {parameters['state']}" + if capability == "interior_light.set_reading_power": + return f"light reading all {parameters['state']}" + if capability == "ambient_light.set_power": + return f"light ambient {parameters['state']}" + if capability == "ambient_light.set_color_index": + return f"light ambient color {parameters['color_index']}" + if capability == "ambient_light.set_brightness_level": + return f"light ambient brightness {parameters['brightness_level']}" + if capability == "roof.set_shade_motion": + return f"shade {parameters['motion']}" + if capability == "roof.set_shade_position": + return f"shade position {parameters['position_pct']}" + return capability + @staticmethod def _report( function_config: dict[str, Any], @@ -380,6 +482,23 @@ class NativeCanRuntime: target = operation["target"]["zone"] parameters = operation["parameters"] + if capability == "door.set_rear_lock": + self._reject_unexpected_parameters(parameters, {"state"}) + state = self._enum(parameters, "state", {"lock": 1, "unlock": 2}) + payload = bytearray(8) + payload[0] = 0x0A + _set_bits(payload, 14, 2, state) + return [ + self._classic( + 0x32B, + payload, + 1, + 0, + (0x407,), + f"rear_lock:{parameters['state']}", + ) + ] + if capability in {"climate.set_power", "climate.set_ac"}: state = self._enum(parameters, "state", {"on": 1, "off": 2}) payload = bytearray(8) @@ -412,6 +531,21 @@ class NativeCanRuntime: payload = bytearray(8) _set_bits(payload, 16, 2, mode) return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_control:{mode}")] + if capability == "climate.set_cycle_mode": + self._reject_unexpected_parameters(parameters, {"mode"}) + mode = self._enum(parameters, "mode", {"internal": 1, "external": 2}) + payload = bytearray(8) + _set_bits(payload, 19, 2, mode) + return [ + self._classic( + 0x1DE, + payload, + 3, + 100, + (0x404,), + f"climate_cycle:{parameters['mode']}", + ) + ] if capability == "climate.set_temperature": temperature = self._integer(parameters, "temperature_c", 18, 32) starts = { @@ -478,6 +612,12 @@ class NativeCanRuntime: if capability == "seat.set_driver_axis_native": self._require_driver_seat_target(target) return [self._driver_seat_axis_frame(parameters)] + if capability in { + "seat.set_driver_position_native", + "seat.recall_driver_memory_native", + }: + self._require_driver_seat_target(target) + return [self._driver_seat_memory_frame(parameters)] if capability in {"outlet.set_position_percent", "outlet.set_calibrated_position"}: return [self._outlet_frame(capability, target, parameters)] @@ -810,6 +950,78 @@ class NativeCanRuntime: }, ) + def _driver_seat_memory_frame( + self, parameters: dict[str, Any] + ) -> NativeFrameSpec: + """Compile one learned PAD memory slot without inventing payload bytes.""" + + self._reject_unexpected_parameters(parameters, {"slot"}) + slot = self._integer(parameters, "slot", 1, 3) + configured = os.getenv( + "COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH", "" + ).strip() + path = ( + Path(configured).expanduser() + if configured + else Path("/home/orin/project511/project-code/canfd/driver_seat_memory.json") + ) + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise NativeCanCompileError( + f"cannot load learned driver-seat memory file {path}: {exc}" + ) from exc + if document.get("version") != 1 or not isinstance( + document.get("positions"), dict + ): + raise NativeCanCompileError( + "driver-seat memory file must use version 1 with positions" + ) + learned = document["positions"].get(str(slot)) + if not isinstance(learned, dict): + raise NativeCanCompileError( + f"driver-seat memory slot {slot} has not been learned from PAD" + ) + + def learned_payload(name: str, sub_id: int) -> bytes: + raw = learned.get(name) + try: + payload = bytes.fromhex(str(raw)) + except ValueError as exc: + raise NativeCanCompileError( + f"driver-seat memory slot {slot} has invalid {name}" + ) from exc + if len(payload) != 8 or payload[0] != sub_id: + raise NativeCanCompileError( + f"driver-seat memory slot {slot} {name} must be an 8-byte " + f"0x{sub_id:02X} learned frame" + ) + return payload + + payload_15 = learned_payload("payload_15", 0x15) + payload_16 = learned_payload("payload_16", 0x16) + sequence = [ + value.hex(" ").upper() + for _ in range(10) + for value in (payload_16, payload_15) + ] + return NativeFrameSpec( + frame_id=0x4C1, + payload=payload_16, + is_fd=True, + bitrate_switch=True, + feedback_ids=(0x494,), + expected_feedback=f"driver_seat_memory_status:{slot}", + special_handler="driver_seat_memory_recall", + special_parameters={ + "slot": slot, + "fresh_status_timeout_ms": 2_500, + "post_status_timeout_ms": 1_200, + "period_ms": 50, + "frame_sequence": sequence, + }, + ) + def _all_reading_light_frame( self, parameters: dict[str, Any], feedback_family: str ) -> NativeFrameSpec: @@ -876,9 +1088,11 @@ class NativeCanExecutor: bus_factory: Callable[[], CanBus], *, feedback_timeout_ms: int = 1_200, + feedback_required: bool = True, ): self.bus_factory = bus_factory self.feedback_timeout_ms = feedback_timeout_ms + self.feedback_required = feedback_required def execute( self, @@ -896,7 +1110,13 @@ class NativeCanExecutor: try: for command in command_plan["spec"]["commands"]: operation = operations[command["operation_ref"]] + publication_start = len( + getattr(bus, "published_messages", ()) + ) try: + set_debug_command = getattr(bus, "set_debug_command", None) + if callable(set_debug_command): + set_debug_command(command["signals"].get("debug_command", "")) raw_feedback_ids = str( command["signals"].get("feedback_ids", "") ).strip() @@ -915,6 +1135,10 @@ class NativeCanExecutor: sent_frames, feedback = self._execute_driver_seat_axis( bus, command ) + elif special_handler == "driver_seat_memory_recall": + sent_frames, feedback = self._execute_driver_seat_memory( + bus, command + ) elif special_handler == "shade_preset": sent_frames, feedback = self._execute_shade_preset( bus, command @@ -927,8 +1151,10 @@ class NativeCanExecutor: sent_frames = self._send_command(bus, command) feedback = self._wait_feedback(bus, command) feedback_match = self._feedback_matches(command, feedback) - feedback_required = True + feedback_required = self.feedback_required verified = feedback is not None and feedback_match is True + delivered = bool(sent_frames) + succeeded = verified or (delivered and not feedback_required) physical_channel = ( feedback.channel if feedback is not None @@ -936,13 +1162,24 @@ class NativeCanExecutor: if sent_frames else None ) + mqtt_messages = list( + getattr(bus, "published_messages", ())[ + publication_start: + ] + ) results.append( { "ref": operation["id"], "domain": function_config["spec"]["domain"], - "status": "succeeded" if verified else "failed", + "status": "succeeded" if succeeded else "failed", "message": ( - "CAN 盒已提交车端控制帧并观察到匹配反馈。" + "MQTT 白名单控制帧已发布,并收到匹配的车辆状态反馈。" + if not feedback_required and feedback_match is True + else "MQTT 白名单控制帧已发布;已收到状态帧,但目标值尚未确认。" + if not feedback_required and feedback is not None + else "MQTT 白名单控制帧已发布;车辆状态反馈尚未确认。" + if not feedback_required + else "CAN 盒已提交车端控制帧并观察到匹配反馈。" if feedback_match is True else "CAN 盒已提交车端控制帧,但反馈字段与目标值不一致。" if feedback_match is False @@ -960,8 +1197,24 @@ class NativeCanExecutor: "feedback_match": feedback_match, "feedback_state": self._feedback_state(feedback), "feedback_required": feedback_required, + "delivery_status": ( + "published" + if not feedback_required and delivered + else "submitted" + ), + "physical_effect_confirmed": verified, + "control_gate": ( + "compiled_action_and_frame_whitelist" + if not feedback_required + else "stationary_vehicle_and_feedback" + ), "channel": physical_channel, "simulated": False, + **( + {"mqtt_messages": mqtt_messages} + if mqtt_messages + else {} + ), }, } ) @@ -1062,6 +1315,62 @@ class NativeCanExecutor: raise RuntimeError("; ".join(details)) return sent, self._wait_feedback(bus, command) + def _execute_driver_seat_memory( + self, bus: CanBus, command: dict[str, Any] + ) -> tuple[list[CanFrame], CanFrame | None]: + """Require fresh initialization, then replay the learned 16/15 sequence.""" + + signals = command["signals"] + special = json.loads(str(signals["special_parameters"])) + before = self._wait_for_id( + bus, 0x494, int(special["fresh_status_timeout_ms"]) + ) + initialized = ( + decode_feedback(before) + .get("actuator_state", {}) + .get("driver", {}) + .get("position_feedback", {}) + .get("initialized", {}) + if before is not None + else {} + ) + axes = ("horizontal", "backrest", "height", "basin") + if not all(initialized.get(axis) is True for axis in axes): + raise RuntimeError( + "no fresh 0x494 proving all four driver-seat axes initialized; " + "refusing memory recall" + ) + + frame_id = int(str(signals["frame_id"]), 0) + sequence = [ + bytes.fromhex(str(value)) for value in special["frame_sequence"] + ] + if ( + len(sequence) != 20 + or any(len(payload) != 8 for payload in sequence) + or any( + payload[0] != (0x16 if index % 2 == 0 else 0x15) + for index, payload in enumerate(sequence) + ) + ): + raise RuntimeError("compiled driver-seat memory sequence is invalid") + sent: list[CanFrame] = [] + for index, payload in enumerate(sequence): + sent.append( + bus.send( + frame_id, + payload, + is_fd=True, + bitrate_switch=True, + ) + ) + if index + 1 < len(sequence): + time.sleep(int(special["period_ms"]) / 1_000) + feedback = self._wait_for_id( + bus, 0x494, int(special["post_status_timeout_ms"]) + ) + return sent, feedback + def _execute_shade_preset( self, bus: CanBus, command: dict[str, Any] ) -> tuple[list[CanFrame], CanFrame | None]: diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 15d248e..5a8db50 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -6,7 +6,6 @@ import json import os import re import sys -import tempfile import time import uuid from concurrent.futures import ThreadPoolExecutor @@ -121,6 +120,7 @@ def _clean_vehicle_number(value: float | None) -> int | float | None: CONVERSATION_ID_PATTERN = re.compile(r"^conv-[a-z0-9-]{6,80}$") +MODE_EDITOR_REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]{6,100}$") SEAT_SCOPE_PATTERN = re.compile(r"\bseats?\b|座椅|坐椅", re.IGNORECASE) DRIVER_SEAT_SCOPE_PATTERN = re.compile( r"\bdriver\b|\bfront[-_ ]?left\b|\bleft[-_ ]?front\b|主驾|驾驶位|司机位|左前", @@ -137,6 +137,18 @@ VEHICLE_CONTROL_TRANSPORTS = frozenset( ) +def _wireless_mqtt_publish_preflight() -> dict[str, Any]: + """Describe the non-physical gate used before an approved MQTT publish.""" + + return { + "transport": WIRELESS_MQTT_TRANSPORT, + "status": "delegated", + "policy": "mqtt_whitelist_publish", + "control_gate": "capability_parameter_and_frame_whitelist", + "required_vehicle_frames": [], + } + + def _has_mixed_driver_passenger_seat_scope(utterance: str) -> bool: """Detect one request that crosses the CH1-driver and CH0-passenger boundary.""" @@ -267,13 +279,19 @@ class SeatDemoService: } self.model_plan_adapter = ModelPlanAdapter(self.capability_context) self.vehicle_profiles = VehicleProfileRegistry(repository_root / "config" / "vehicles") - self._mode_session_directory = tempfile.TemporaryDirectory( - prefix="cognitive-agent-mode-session-", - ignore_cleanup_errors=True, + configured_mode_root = os.getenv( + "COGNITIVE_AGENT_MODE_PROFILE_ROOT", "" + ).strip() + self.mode_profile_user_root = ( + Path(configured_mode_root).expanduser() + if configured_mode_root + else repository_root / "artifacts" / "mode-profiles" ) + if not self.mode_profile_user_root.is_absolute(): + self.mode_profile_user_root = repository_root / self.mode_profile_user_root self.mode_profiles = ModeProfileRegistry( repository_root / "config" / "modes", - user_root=Path(self._mode_session_directory.name), + user_root=self.mode_profile_user_root, ) self._artifact_root = repository_root / "artifacts" / "runs" self.conversations = ConversationStateStore( @@ -425,7 +443,8 @@ class SeatDemoService: "vehicle": default_vehicle.public_dict(), "vehicles": self.vehicle_profiles.public_profiles(), "modes": self.mode_profiles.public_modes(default_vehicle.id), - "mode_profile_storage": "runtime_session_volatile", + "mode_profile_storage": "persistent_yaml", + "mode_profile_user_root": str(self.mode_profile_user_root), "preset_storage": "conversation_durable", "inferred_level_range": [1, 3], "motion_position_range_pct": [0, 100], @@ -476,6 +495,204 @@ class SeatDemoService: "vehicle_id": resolved_vehicle_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, Any]: + """Persist one edited mode from a server-generated CAN command flow.""" + + resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id + clean_mode_id = str(mode_id).strip() + clean_name = str(name).strip() + clean_instruction = str(instruction).strip() + clean_request_id = str(source_request_id).strip() + if not clean_name: + raise ValueError("模式名称不能为空。") + if len(clean_name) > 64: + raise ValueError("模式名称不能超过 64 个字符。") + if not clean_instruction: + raise ValueError("自然语言指令不能为空。") + if len(clean_instruction) > 1000: + raise ValueError("自然语言指令不能超过 1000 个字符。") + if not MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(clean_request_id): + raise ValueError("CAN 指令流来源请求编号无效。") + + visible_profiles = self.mode_profiles._profiles_for_vehicle( # noqa: SLF001 + resolved_vehicle_id + ) + profile = next( + (item for item in visible_profiles if item.id == clean_mode_id), + None, + ) + if profile is None: + raise KeyError(clean_mode_id) + normalized_name = re.sub(r"\s+", "", clean_name).casefold() + if any( + item.id != profile.id + and re.sub(r"\s+", "", item.name).casefold() == normalized_name + for item in visible_profiles + ): + raise ValueError("该模式名称已被其他模式使用。") + + result_path = self.artifact_root / clean_request_id / "result.json" + if not result_path.is_file(): + raise ValueError("未找到执行按钮生成的 CAN 指令流,请重新执行。") + try: + source_result = json.loads(result_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError("CAN 指令流来源结果无法读取,请重新执行。") from exc + if not isinstance(source_result, dict) or source_result.get( + "request_id" + ) != clean_request_id: + raise ValueError("CAN 指令流来源结果不完整,请重新执行。") + recorded_instruction = str(source_result.get("utterance", "")).strip() + if not recorded_instruction: + source_spans = { + str(item.get("source_span", "")).strip() + for item in ( + (source_result.get("intent_ir") or {}).get("spec", {}).get( + "requests", [] + ) + ) + if isinstance(item, dict) and str(item.get("source_span", "")).strip() + } + if len(source_spans) == 1: + recorded_instruction = next(iter(source_spans)) + if recorded_instruction and recorded_instruction != clean_instruction: + raise ValueError("自然语言指令已修改,请重新点击执行生成 CAN 指令流。") + + plans = source_result.get("command_plans") or {} + configs = source_result.get("function_configs") or {} + if not isinstance(plans, dict) or not isinstance(configs, dict): + raise ValueError("执行结果中没有可永久化的 CAN 指令流。") + executable_plan_keys = { + key + for key, plan in plans.items() + if isinstance(plan, dict) + and isinstance(plan.get("spec"), dict) + and isinstance(plan["spec"].get("commands"), list) + and plan["spec"]["commands"] + } + if not executable_plan_keys: + raise ValueError("请先点击执行并成功生成 CAN 控制指令流。") + + operations: list[dict[str, Any]] = [] + signatures: set[tuple[str, str, str, str]] = set() + for key in plans: + if key not in executable_plan_keys: + continue + config = configs.get(key) + if not isinstance(config, dict): + continue + spec = config.get("spec") or {} + domain = str(spec.get("domain", "")).strip() + for operation in spec.get("operations", []): + if not isinstance(operation, dict): + continue + capability = str(operation.get("capability", "")).strip() + target = str((operation.get("target") or {}).get("zone", "")).strip() + parameters = operation.get("parameters") or {} + if not domain or not capability or not target or not isinstance( + parameters, dict + ): + continue + signature = ( + domain, + capability, + target, + json.dumps(parameters, ensure_ascii=False, sort_keys=True), + ) + if signature in signatures: + continue + signatures.add(signature) + operations.append( + { + "domain": domain, + "capability": capability, + "target_scope": target, + "operation": "set", + "parameters": deepcopy(parameters), + "reason": f"由模式编辑器请求 {clean_request_id} 固化。", + } + ) + if not operations: + raise ValueError("CAN 指令流没有关联到可复用的模式操作。") + + execution_steps = [] + for item in ( + (source_result.get("execution_report") or {}).get("spec", {}).get( + "results", [] + ) + ): + if not isinstance(item, dict): + continue + telemetry = item.get("telemetry") or {} + command_evidence = { + key: deepcopy(telemetry[key]) + for key in ( + "command_frame", + "sent_frames", + "mqtt_messages", + "response_frame", + "requested_parameters", + ) + if key in telemetry + } + if command_evidence: + execution_steps.append( + { + "ref": item.get("ref"), + "domain": item.get("domain"), + "status": item.get("status"), + **command_evidence, + } + ) + command_flow = { + "schema": "cognitive-agent-mode-command-flow/v1", + "source_request_id": clean_request_id, + "generated_at": _now(), + "execution_mode": source_result.get("execution_mode"), + "control_transport": source_result.get("control_transport"), + "function_configs": { + key: deepcopy(configs[key]) + for key in plans + if key in executable_plan_keys and key in configs + }, + "plans": {key: deepcopy(plans[key]) for key in plans if key in executable_plan_keys}, + "execution_steps": execution_steps, + } + saved = self.mode_profiles.save_mode( + clean_name, + operations, + mode_id=profile.id, + aliases=list(profile.aliases), + vehicle_id=resolved_vehicle_id, + description=profile.description, + icon=profile.icon, + instruction=clean_instruction, + command_flow=command_flow, + timeline=profile.timeline, + ) + public_profile = next( + ( + item + for item in self.mode_profiles.public_modes(resolved_vehicle_id) + if item["id"] == saved.id + ), + saved.public_dict(), + ) + return { + "status": "succeeded", + "mode_profile": public_profile, + "vehicle_id": resolved_vehicle_id, + "storage": "persistent_yaml", + "source_request_id": clean_request_id, + } + def synchronize_hardware_state( self, conversation_id: str, @@ -999,6 +1216,7 @@ class SeatDemoService: traces: list[dict[str, Any]] = [] result: dict[str, Any] = { "request_id": request_id, + "utterance": utterance, "conversation_id": conversation_id, "conversation_turn": context["turn_count"], "status": "processing", @@ -1118,6 +1336,12 @@ class SeatDemoService: ), ) if hardware_request and not control_plane_request: + if ( + control_transport == WIRELESS_MQTT_TRANSPORT + and parsed.get("domain_actions") + and (parsed.get("actions") or parsed.get("native_can_actions")) + ): + parsed = self._defer_nonwhitelisted_wireless_actions(parsed) if parsed.get("domain_actions"): unsupported = sorted( { @@ -1180,7 +1404,7 @@ class SeatDemoService: if provider_notice: parsed["decision_summary"].append(provider_notice) result["provider_used"] = provider_used - if provider_used.startswith("openai:"): + if "openai:" in provider_used: result["reasoning_effort_used"] = effective_reasoning_effort result["decision_summary"] = parsed["decision_summary"] result["deferred_requests"] = parsed.get("deferred_requests", []) @@ -1598,21 +1822,7 @@ class SeatDemoService: mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT vehicle_safety_preflight = None if mqtt_selected: - try: - vehicle_safety_preflight = observe_vehicle_safety( - self._vehicle_mqtt_transport.bus(channel=1), - timeout_ms=min( - 5_000, - max(1_000, self.vehicle_mqtt_settings.feedback_timeout_ms), - ), - required_channel=1, - ) - require_stationary_vehicle(vehicle_safety_preflight) - except VehicleSafetyPreflightError as exc: - raise HardwareSafetyError( - "CAN1 vehicle safety preflight refused wireless passenger-seat execution: " - f"{exc}" - ) from exc + vehicle_safety_preflight = _wireless_mqtt_publish_preflight() hardware_executor = self._new_hardware_executor( lambda: self._vehicle_mqtt_transport.bus(channel=0), remote_transport=True, @@ -3122,19 +3332,69 @@ class SeatDemoService: ) ] for item in deferred_requests: - if item.get("capability") in { - "door.set_vehicle_lock", - "door.set_rear_lock", - }: + if item.get("capability") == "door.set_vehicle_lock": item["execution_backend"] = "unsupported" item["rejection_code"] = "CH1_DOOR_LOCK_CONTROL_UNSUPPORTED" item["reason"] = ( - "Current CH1 door-lock control is unsupported; lock feedback " - "is observational only and no verified physical command will be built." + "Current CH1 whole-vehicle door-lock control is unsupported; " + "lock feedback is observational only and no verified physical " + "command will be built." ) result["deferred_requests"] = deferred_requests return result + @staticmethod + def _defer_nonwhitelisted_wireless_actions( + parsed: dict[str, Any], + ) -> dict[str, Any]: + """Keep approved MQTT actions executable when a model adds virtual ones.""" + + result = deepcopy(parsed) + deferred_requests = list(result.get("deferred_requests", [])) + existing = { + ( + str(item.get("domain", "")), + str(item.get("capability", "")), + str(item.get("target", item.get("target_scope", "vehicle"))), + ) + for item in deferred_requests + } + for action in result.get("domain_actions", []): + target = str( + action.get("target", action.get("target_scope", "vehicle")) + ) + signature = ( + str(action.get("domain", "unknown")), + str(action.get("capability", "unknown")), + target, + ) + if signature in existing: + continue + parameters = dict(action.get("parameters", {})) + requested_value: Any = parameters + if len(parameters) == 1: + requested_value = next(iter(parameters.values())) + deferred_requests.append( + { + "domain": signature[0], + "capability": signature[1], + "target_scope": target, + "desired_change": str(action.get("operation", "set")), + "requested_value": requested_value, + "parameters": parameters, + "reason": ( + "该模型动作不在无线 MQTT CAN 控制白名单中,已单独跳过;" + "同一句中的白名单动作继续发布。" + ), + "execution_backend": "unsupported", + "rejection_code": "WIRELESS_MQTT_CONTROL_NOT_WHITELISTED", + } + ) + existing.add(signature) + result["domain_actions"] = [] + result["deferred_requests"] = deferred_requests + return result + def _process_orchestrated( self, utterance: str, @@ -3415,7 +3675,12 @@ class SeatDemoService: "safety_check", "succeeded", ( - "所有动作已通过 CH1 车身 CAN 白名单和物理反馈门禁。" + "所有动作已通过无线 MQTT 能力、参数及 CAN 帧白名单门禁。" + if ( + hardware_request + and control_transport == WIRELESS_MQTT_TRANSPORT + ) + else "所有动作已通过 CH1 车身 CAN 白名单和物理反馈门禁。" if hardware_request and native_config_keys and "seat" not in configs else "副驾 T2G 台架动作已通过独立硬件安全门。" if hardware_request @@ -3526,7 +3791,14 @@ class SeatDemoService: "labels": { "simulation": "mixed" if hardware_request else "true", "seat_feedback": ( - "physical_can_ch1" + "wireless_mqtt_ch1" + if ( + hardware_request + and control_transport == WIRELESS_MQTT_TRANSPORT + and native_config_keys + and "seat" not in configs + ) + else "physical_can_ch1" if hardware_request and native_config_keys and "seat" not in configs else "physical_can" if hardware_request @@ -3570,7 +3842,14 @@ class SeatDemoService: "execute", "succeeded" if execution_succeeded and not report_issues else "failed", ( - "CH1 车身 CAN 动作已按目标字段物理回读闭环执行。" + "无线 MQTT 白名单控制帧已发布;车辆实际状态以状态话题回读为准。" + if ( + hardware_request + and control_transport == WIRELESS_MQTT_TRANSPORT + and native_config_keys + and execution_succeeded + ) + else "CH1 车身 CAN 动作已按目标字段物理回读闭环执行。" if hardware_request and native_config_keys and execution_succeeded else "副驾座椅已按独立 T2G 物理 CAN 回读闭环执行。" if hardware_request and execution_succeeded @@ -3800,35 +4079,39 @@ class SeatDemoService: seat_channel = int(self.hardware_settings.channel, 0) seat_bus_factory = lambda: bridge.channel_bus(seat_channel) - # The bridge is open but no control frame has been submitted yet. - # Require two independent, fresh CH1 status frames before entering - # any actuator executor; CH0 traffic is ignored by the observer. - try: - vehicle_safety_preflight = observe_vehicle_safety( - native_bus_factory(), - timeout_ms=min( - 5_000, - max( - 1_000, - self.vehicle_mqtt_settings.feedback_timeout_ms - if mqtt_selected - else self.vehicle_can_settings.feedback_timeout_ms, + if mqtt_selected: + # The head unit is the physical CAN owner. Once an action has + # passed the capability/parameter compiler, MqttCanTransport + # applies the final channel, ID and frame-format allow-list at + # publish time. Missing 0x342/0x4EB state frames must not + # suppress an otherwise approved MQTT command. + vehicle_safety_preflight = _wireless_mqtt_publish_preflight() + else: + # The wired bridge is open but no control frame has been + # submitted yet. Require two independent, fresh CH1 status + # frames before entering any actuator executor; CH0 traffic is + # ignored by the observer. + try: + vehicle_safety_preflight = observe_vehicle_safety( + native_bus_factory(), + timeout_ms=min( + 5_000, + max(1_000, self.vehicle_can_settings.feedback_timeout_ms), ), - ), - required_channel=1, - ) - require_stationary_vehicle(vehicle_safety_preflight) - except VehicleSafetyPreflightError as exc: - if bridge is not None: - bridge.close() - raise HardwareSafetyError( - "CH1 vehicle safety preflight refused physical execution: " - f"{exc}" - ) from exc - except Exception: - if bridge is not None: - bridge.close() - raise + required_channel=1, + ) + require_stationary_vehicle(vehicle_safety_preflight) + except VehicleSafetyPreflightError as exc: + if bridge is not None: + bridge.close() + raise HardwareSafetyError( + "CH1 vehicle safety preflight refused physical execution: " + f"{exc}" + ) from exc + except Exception: + if bridge is not None: + bridge.close() + raise try: def execute_native_configs() -> dict[str, dict[str, Any]]: @@ -3846,6 +4129,7 @@ class SeatDemoService: if control_transport == WIRELESS_MQTT_TRANSPORT else self.vehicle_can_settings.feedback_timeout_ms ), + feedback_required=not mqtt_selected, ) native_reports[key] = executor.execute( self.native_can_runtime, @@ -3959,6 +4243,148 @@ class SeatDemoService: }, } + @staticmethod + def _split_intent_clauses(utterance: str) -> list[str]: + fragments = [ + item.strip() + for item in re.split(r"[,,。;;::!!??]+", utterance) + if item.strip() + ] + clauses: list[str] = [] + for fragment in fragments: + # An exclusion is a qualifier of the control immediately before it, + # not an independent command. Keeping both pieces together lets + # the deterministic window parser remove the excluded targets + # before the command list is finalized. + if clauses and re.search(r"除外|除去|不包括|不含", fragment): + clauses[-1] = f"{clauses[-1]},{fragment}" + else: + clauses.append(fragment) + return clauses or [utterance.strip()] + + @staticmethod + def _local_clause_status(parsed: dict[str, Any], clause: str) -> str: + """Classify one deterministic parse as complete, partial, or unparsed.""" + + controls = any( + parsed.get(key) + for key in ( + "actions", + "domain_actions", + "native_can_actions", + "preset_actions", + "preset_events", + "semantic_constraints", + ) + ) + real_ambiguities = [ + item + for item in parsed.get("ambiguities", []) + if str(item.get("field", "")) != "capability" + ] + unresolved = bool(parsed.get("unresolved_spans")) + # Scene/fuzzy-goal language may contain valid atomic controls and still + # ask for additional orchestration. Keep the trusted local controls but + # give only that clause to the model for the uncovered meaning. + fuzzy_goal = bool( + re.search( + r"观影|影院|看电影|休息|睡眠|露营|播放|音乐|场景|" + r"舒适(?:点|一些)?|凉快(?:点|一些)?|暖和(?:点|一些)?", + clause, + re.IGNORECASE, + ) + ) + if controls and not real_ambiguities and not unresolved and not fuzzy_goal: + return "complete" + if controls: + return "partial" + return "unparsed" + + @classmethod + def _merge_parsed_results( + cls, results: list[dict[str, Any]] + ) -> dict[str, Any]: + """Merge clause-level parses while retaining their source order.""" + + merged: dict[str, Any] = { + "actions": [], + "domain_actions": [], + "native_can_actions": [], + "preset_actions": [], + "preset_events": [], + "deferred_requests": [], + "model_requests": [], + "ambiguities": [], + "decision_summary": [], + "semantic_constraints": [], + "unresolved_spans": [], + "detected_domains": [], + "confidence": 0.0, + } + list_fields = tuple( + key + for key in merged + if key not in {"detected_domains", "confidence"} + ) + for parsed in results: + for key in list_fields: + merged[key] = cls._merge_unique( + merged[key], list(parsed.get(key, [])) + ) + merged["detected_domains"] = cls._merge_domains( + merged["detected_domains"], + list(parsed.get("detected_domains", [])), + ) + merged["confidence"] = max( + float(merged["confidence"]), float(parsed.get("confidence", 0)) + ) + return merged + + @classmethod + def _merge_local_and_model_intent( + cls, + local: dict[str, Any], + model: dict[str, Any], + ) -> dict[str, Any]: + """Merge a fallback model plan without overriding local controls.""" + + merged = deepcopy(model) + for key in ("actions", "domain_actions", "native_can_actions"): + local_items = list(local.get(key, [])) + local_signatures = { + cls._control_signature(item) for item in local_items + } + model_items = [ + item + for item in model.get(key, []) + if cls._control_signature(item) not in local_signatures + ] + merged[key] = cls._merge_unique(local_items, model_items) + for key in ( + "preset_actions", + "preset_events", + "deferred_requests", + "model_requests", + "ambiguities", + "semantic_constraints", + "unresolved_spans", + ): + merged[key] = cls._merge_unique( + list(local.get(key, [])), list(model.get(key, [])) + ) + merged["decision_summary"] = cls._merge_unique( + list(local.get("decision_summary", [])), + list(model.get("decision_summary", [])), + ) + merged["detected_domains"] = cls._merge_domains( + list(local.get("detected_domains", [])), + list(model.get("detected_domains", [])), + ) + merged["confidence"] = max( + float(local.get("confidence", 0)), float(model.get("confidence", 0)) + ) + return merged + def _interpret( self, utterance: str, @@ -3969,69 +4395,127 @@ class SeatDemoService: reasoning_effort: str | None = None, ) -> tuple[dict[str, Any], str, str | None]: vehicle_profile = vehicle_profile or self.vehicle_profiles.default() - if provider == "local": - return parse_local_intent(utterance, context), "local_rules", None - if provider == "openai": - if not self.openai.available: - raise ValueError("GPT 模式需要设置 OPENAI_API_KEY") - try: - interpret_options = {} - if reasoning_effort is not None: - interpret_options["reasoning_effort"] = reasoning_effort - plan = self.openai.interpret( - utterance, - context, - self.capability_context, - **interpret_options, + clause_results: list[tuple[str, dict[str, Any], str]] = [] + + def parse_clause(clause: str) -> dict[str, Any]: + local = parse_local_intent(clause, context) + relative = ( + self.native_can_relative_resolver.resolve(clause, context) + if self.native_can_relative_resolver.matches(clause) + else None + ) + local = merge_native_can_intent( + local, + clause, + relative_actions=relative.actions if relative else None, + relative_ambiguities=relative.ambiguities if relative else None, + relative_claimed_controls=( + relative.claimed_controls if relative else None + ), + ) + return local + + def has_seat_domain(parsed: dict[str, Any]) -> bool: + if parsed.get("actions") or "seat" in parsed.get("detected_domains", []): + return True + for key in ( + "domain_actions", + "native_can_actions", + "deferred_requests", + "model_requests", + ): + if any(item.get("domain") == "seat" for item in parsed.get(key, [])): + return True + return any( + str(item.get("field", "")).startswith("seat.") + for item in parsed.get("ambiguities", []) + ) + + inherited_seat_scope: str | None = None + for raw_clause in self._split_intent_clauses(utterance): + clause = raw_clause + local = parse_clause(clause) + clause_has_seat = has_seat_domain(local) + explicit_driver = bool(DRIVER_SEAT_SCOPE_PATTERN.search(raw_clause)) + explicit_passenger = bool(PASSENGER_SEAT_SCOPE_PATTERN.search(raw_clause)) + if clause_has_seat and explicit_driver != explicit_passenger: + inherited_seat_scope = "driver" if explicit_driver else "passenger" + elif clause_has_seat and inherited_seat_scope: + # Chinese compound controls commonly state the seat once and + # then list axes or thermal functions after commas. Carry the + # last explicit seat scope only into later seat clauses; this + # prevents a passenger request from silently falling through + # to the CH1 driver default while leaving climate/door/window + # clauses untouched. + scope_label = ( + "主驾座椅" + if inherited_seat_scope == "driver" + else "副驾座椅" ) - return ( - self.model_plan_adapter.adapt( - plan, - context, - vehicle_profile, - utterance=utterance, - ), - f"openai:{self.openai.model}", - None, - ) - except OpenAIIntentError: - return ( - parse_local_intent(utterance, context), - "local_rules_fallback", - "GPT 调用失败,已降级到本地规则解析;CAN 编译和执行路径不变。", - ) - if self.openai.available: - try: - interpret_options = {} - if reasoning_effort is not None: - interpret_options["reasoning_effort"] = reasoning_effort - plan = self.openai.interpret( - utterance, - context, - self.capability_context, - **interpret_options, - ) - return ( - self.model_plan_adapter.adapt( - plan, - context, - vehicle_profile, - utterance=utterance, - ), - f"openai:{self.openai.model}", - None, - ) - except OpenAIIntentError: - return ( - parse_local_intent(utterance, context), - "local_rules_fallback", - "GPT 调用失败,已降级到本地规则解析;CAN 编译和执行路径不变。", - ) - return ( - parse_local_intent(utterance, context), - "local_rules", - "未配置 OPENAI_API_KEY,本次使用可重复的本地规则解析。", + clause = f"{scope_label},{raw_clause}" + local = parse_clause(clause) + clause_results.append( + (clause, local, self._local_clause_status(local, clause)) + ) + + trusted_local = self._merge_parsed_results( + [parsed for _, parsed, status in clause_results if status != "unparsed"] ) + all_local = self._merge_parsed_results( + [parsed for _, parsed, _ in clause_results] + ) + fallback_clauses = [ + clause for clause, _, status in clause_results if status != "complete" + ] + trusted_controls = any( + trusted_local.get(key) + for key in ("actions", "domain_actions", "native_can_actions") + ) + safe_fallback = trusted_local if trusted_controls else all_local + if provider == "local" or not fallback_clauses: + return ( + all_local if provider == "local" else trusted_local, + "local_rules", + None, + ) + + if not self.openai.available: + if provider == "openai": + raise ValueError("存在本地规则无法完整解析的子句;GPT 模式需要设置 OPENAI_API_KEY") + return ( + safe_fallback, + "local_rules", + "未配置 OPENAI_API_KEY;已保留本地规则结果,未解析子句没有下发控制。", + ) + + fallback_utterance = ",".join(fallback_clauses) + try: + interpret_options = {} + if reasoning_effort is not None: + interpret_options["reasoning_effort"] = reasoning_effort + plan = self.openai.interpret( + fallback_utterance, + context, + self.capability_context, + **interpret_options, + ) + model = self.model_plan_adapter.adapt( + plan, + context, + vehicle_profile, + utterance=fallback_utterance, + ) + return ( + self._merge_local_and_model_intent(trusted_local, model), + f"local_rules+openai:{self.openai.model}", + "本地规则先完成确定性解析;仅未覆盖或有歧义的子句交由 GPT 补充。", + ) + except OpenAIIntentError: + return ( + safe_fallback, + "local_rules_fallback", + "GPT 调用失败,已降级到本地规则解析(逐子句);已保留确定性结果,CAN 编译和执行路径不变。", + ) @staticmethod def _context_for_vehicle( diff --git a/src/cognitive_agent/application/vehicle_state_monitor.py b/src/cognitive_agent/application/vehicle_state_monitor.py index a8daa8c..07eb414 100644 --- a/src/cognitive_agent/application/vehicle_state_monitor.py +++ b/src/cognitive_agent/application/vehicle_state_monitor.py @@ -18,7 +18,7 @@ REQUIRED_SEAT_IDS = frozenset({0x4FA, 0x494}) REQUIRED_BODY_IDS = frozenset({0x342, 0x4EB, 0x407}) REQUIRED_UI_IDS = REQUIRED_SEAT_IDS | REQUIRED_BODY_IDS OBSERVABLE_IDS = REQUIRED_UI_IDS | frozenset( - {0x388, 0x3F3, 0x3FF, 0x404, 0x41A, 0x42E} + {0x388, 0x38A, 0x3F3, 0x3FF, 0x404, 0x41A, 0x42E} ) PERSISTENT_MONITOR_POLL_INTERVAL_MS = 500 diff --git a/src/cognitive_agent/infrastructure/can/hardware.py b/src/cognitive_agent/infrastructure/can/hardware.py index ed34d3e..3b9c1d0 100755 --- a/src/cognitive_agent/infrastructure/can/hardware.py +++ b/src/cognitive_agent/infrastructure/can/hardware.py @@ -767,7 +767,7 @@ class VehicleCanSettings: @dataclass(frozen=True) class VehicleMqttSettings: - """Wireless CAN0/CAN1 relay settings with the same physical approval gate.""" + """Wireless CAN0/CAN1 relay settings for the local head-unit broker.""" enabled: bool approval_required: bool @@ -789,7 +789,11 @@ class VehicleMqttSettings: return cls( enabled=_env_bool("COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED", default=True), approval_required=_env_bool( - "COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True + "COGNITIVE_AGENT_VEHICLE_MQTT_APPROVAL_TOKEN_REQUIRED", + default=_env_bool( + "COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", + default=False, + ), ), approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN", ""), host=os.getenv("COGNITIVE_AGENT_VEHICLE_MQTT_HOST", "127.0.0.1").strip(), @@ -800,7 +804,7 @@ class VehicleMqttSettings: ).strip(), control_topic=os.getenv( "COGNITIVE_AGENT_VEHICLE_MQTT_CONTROL_TOPIC", - "n7/can_control", + "n7/n7-headunit/can/command", ).strip(), qos=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_QOS", 1), keepalive_s=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_KEEPALIVE_S", 30), @@ -871,6 +875,8 @@ class VehicleMqttSettings: "channels": {"seat": 0, "vehicle": 1}, "target_policy": "dual_channel_mqtt_can0_passenger_can1_vehicle", "frame_contract": "n7.can.frame.v1", + "state_contract": "n7-can-signals/v1", + "control_contract": "n7-headunit-can-command/v1", "can0_seat_protocol": "seat-ota-runtime", "can0_state_contract": "0x6A0-SEAT_INFO-plus-0x6A2-to-0x302", "can0_control_id": "0x6A0", diff --git a/src/cognitive_agent/infrastructure/can/mqtt_transport.py b/src/cognitive_agent/infrastructure/can/mqtt_transport.py index 2a29580..a8f8b26 100644 --- a/src/cognitive_agent/infrastructure/can/mqtt_transport.py +++ b/src/cognitive_agent/infrastructure/can/mqtt_transport.py @@ -27,7 +27,6 @@ from typing import Any from cognitive_agent.application.native_can_feedback import decode_feedback from cognitive_agent.application.vehicle_state_monitor import ( OBSERVABLE_IDS, - REQUIRED_UI_IDS, VehicleStateSnapshot, ) from cognitive_agent.infrastructure.can.gateway import ( @@ -38,16 +37,25 @@ from cognitive_agent.infrastructure.can.hardware import VehicleMqttSettings MQTT_CAN_SCHEMA = "n7.can.frame.v1" MQTT_POLL_INTERVAL_MS = 500 +WIRELESS_MQTT_CH1_SOURCE = "wireless_mqtt_ch1" +MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset( + {*range(9), 12, 16, 20, 24, 32, 48, 64} +) MQTT_SAFETY_FEEDBACK_IDS = frozenset({0x342, 0x4EB}) MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset({0x2F5, 0x37B}) -MQTT_SEAT_FEEDBACK_IDS = frozenset({*range(0x301, 0x308), 0x4FA, 0x6A2}) +# Frames accepted from the Android head unit are the seat-ota wire protocol. +# ``0x302`` is an internal compatibility frame synthesized below so the +# existing seat-domain state reader can consume the four firmware axes; it is +# deliberately not accepted as authoritative MQTT input. +MQTT_SEAT_WIRE_FEEDBACK_IDS = frozenset({0x6A0, 0x6A2}) +MQTT_SEAT_FEEDBACK_IDS = frozenset({0x302, 0x6A2}) MQTT_ACCEPTED_FEEDBACK_IDS = { 0: MQTT_SEAT_FEEDBACK_IDS, 1: MQTT_VEHICLE_FEEDBACK_IDS, } MQTT_APPROVED_CONTROL_IDS = { 0: frozenset({0x6A0}), - 1: frozenset({0x1DE, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}), + 1: frozenset({0x1DE, 0x32B, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}), } MQTT_OTA_SEAT_RUNTIME_OPCODES = frozenset({0x63, 0x64, 0x65, 0x66}) _ID_KEYS = ("arbitration_id", "can_id", "canId", "frame_id", "frameId", "id") @@ -184,8 +192,8 @@ def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]: The seat-ota runtime returns a selector value in a generic response frame: 0x6A0 is ``C7 sequence 60 selector ...`` and 0x6A2 is ``7C sequence 60 status uint32-le``. Only the non-mutating SEAT_INFO - opcode is paired here. Neither OTA identifier is added to an outbound - allow-list or exposed to the ordinary seat executor. + opcode is paired here. The original 0x6A2 response remains available to + command-response waiters; the generated 0x302 frame is internal only. """ selectors_by_request: dict[tuple[int, int], int] = {} @@ -220,19 +228,30 @@ def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]: for axis in range(4): position_entry = values.get(1 + axis) flags_entry = values.get(5 + axis) - if position_entry is None or flags_entry is None: + if position_entry is None: continue packed_position, position_frame = position_entry - flags, flags_frame = flags_entry position = packed_position & 0xFFFF hall_range = (packed_position >> 16) & 0xFFFF if hall_range <= 0 or position > hall_range: continue value_x100 = min(10_000, round(position * 10_000 / hall_range)) - moving = bool(flags & (1 << 1)) - position_valid = bool(flags & (1 << 0)) - driver_fault = bool(flags & (1 << 3)) - direction = (flags >> 8) & 0x03 + if flags_entry is None: + # The production Android poller intentionally requests selectors + # 1..4 only. A valid position/range pair is sufficient to expose + # position, but it does not prove motion or a driver fault. + flags = 0 + flags_frame = position_frame + moving = False + position_valid = True + driver_fault = False + direction = 0 + else: + flags, flags_frame = flags_entry + moving = bool(flags & (1 << 1)) + position_valid = bool(flags & (1 << 0)) + driver_fault = bool(flags & (1 << 3)) + direction = (flags >> 8) & 0x03 task_state = 1 if moving else 0 result = 7 if driver_fault else 0 state_flags = direction @@ -323,29 +342,76 @@ def parse_mqtt_can_payload(payload: bytes | str) -> list[CanFrame]: item_count = sum(len(items) for items, _ in batches) if not 1 <= item_count <= 256: raise ValueError("MQTT CAN frame batch must contain between 1 and 256 frames") - frames = [ + parsed_frames = [ _parse_one_frame(item, default_channel=default_channel) for items, default_channel in batches for item in items ] + frames = [ + frame + for frame in parsed_frames + if frame.arbitration_id + in ( + MQTT_SEAT_WIRE_FEEDBACK_IDS + if frame.channel == 0 + else MQTT_VEHICLE_FEEDBACK_IDS + ) + ] return [*frames, *_ota_seat_status_frames(frames)] -def mqtt_control_payload(frame: CanFrame) -> str: - """Serialize one compiled outbound frame using the documented wire contract.""" +def _validate_headunit_command_frame(frame: CanFrame) -> None: + """Mirror the Android head unit's strict ``CanCommand`` frame checks.""" + + if frame.channel not in {0, 1}: + raise CanGatewayError("MQTT CAN command channel must be CAN0 or CAN1") + if frame.is_fd: + if not frame.bitrate_switch: + raise CanGatewayError("MQTT CAN FD command requires BRS") + if len(frame.data) not in MQTT_VALID_FD_PAYLOAD_LENGTHS: + raise CanGatewayError( + "MQTT CAN FD command length must be 0..8/12/16/20/24/32/48/64" + ) + elif frame.bitrate_switch: + raise CanGatewayError("MQTT classic CAN command cannot enable BRS") + elif len(frame.data) > 8: + raise CanGatewayError("MQTT classic CAN command cannot exceed 8 bytes") + + +def mqtt_control_payload( + frame: CanFrame, + *, + response_timeout_ms: int = 1_500, + request_id: str | None = None, + command: str | None = None, +) -> str: + """Serialize a frame as the Android head unit's strict ``CanCommand`` JSON.""" + + _validate_headunit_command_frame(frame) + command_request_id = request_id or f"cognitive-agent-{uuid.uuid4().hex}" + if not command_request_id.strip() or len(command_request_id) > 128: + raise CanGatewayError("MQTT CAN command requestId must contain 1..128 characters") + debug_command = "" if command is None else str(command).strip() + if len(debug_command) > 256: + raise CanGatewayError("MQTT CAN debug command must contain at most 256 characters") + bounded_timeout_ms = max(100, min(5_000, int(response_timeout_ms))) return json.dumps( { - "schema": MQTT_CAN_SCHEMA, - "arbitration_id": frame.arbitration_id, - "frame_id": f"0x{frame.arbitration_id:03X}", - "channel": frame.channel, - "data_hex": frame.data.hex(" ").upper(), - "data": list(frame.data), - "payload_size": len(frame.data), - "is_fd": frame.is_fd, - "bitrate_switch": frame.bitrate_switch, - "timestamp": time.time(), + "requestId": command_request_id, + # Debug-only semantic metadata. The head unit continues to execute + # only the validated channel/ID/frame/data fields below. + "command": debug_command, + "channel": f"can{frame.channel}", + "canId": f"0x{frame.arbitration_id:03X}", + "frameType": "fd" if frame.is_fd else "classic", + "brs": frame.bitrate_switch, + "extended": False, + "remote": False, + "data": frame.data.hex(" ").upper(), + "repeat": 1, + "intervalMs": 0, + "responseTimeoutMs": bounded_timeout_ms, }, ensure_ascii=False, separators=(",", ":"), @@ -587,6 +653,11 @@ class MqttCanTransport: for key in ("actuator_state", "domain_state", "vehicle_safety") ): return + # The matrix decoder is shared with the wired CH1 monitor and therefore + # emits its wired source marker by default. Preserve the common byte + # decoder while making the actual transport explicit before this state + # reaches the conversation store or browser. + decoded["source"] = WIRELESS_MQTT_CH1_SOURCE frame_id = str(decoded.get("frame_id", "")) captured_at = ( datetime.fromtimestamp(frame.timestamp, UTC) @@ -683,7 +754,12 @@ class MqttCanTransport: ) return MqttCanBus(self, cursor, channel, initial_frames=initial_frames) - def publish(self, frame: CanFrame) -> None: + def publish( + self, + frame: CanFrame, + *, + command: str | None = None, + ) -> dict[str, Any]: if ( frame.channel not in MQTT_APPROVED_CONTROL_IDS or frame.arbitration_id not in MQTT_APPROVED_CONTROL_IDS[frame.channel] @@ -691,6 +767,7 @@ class MqttCanTransport: raise CanGatewayError("MQTT CAN frame is outside the approved channel policy") if frame.channel == 0 and (frame.is_fd or frame.bitrate_switch): raise CanGatewayError("CAN0 passenger-seat control requires classic CAN") + _validate_headunit_command_frame(frame) if frame.channel == 0: _validate_ota_seat_runtime_control(frame.data) with self._condition: @@ -698,9 +775,14 @@ class MqttCanTransport: state = self._state if client is None or state != "online": raise CanGatewayError("MQTT CAN relay is not connected") + raw_payload = mqtt_control_payload( + frame, + response_timeout_ms=self.settings.feedback_timeout_ms, + command=command, + ) result = client.publish( self.settings.control_topic, - mqtt_control_payload(frame), + raw_payload, qos=self.settings.qos, retain=False, ) @@ -710,6 +792,12 @@ class MqttCanTransport: published = result.wait_for_publish(timeout=2.0) if published is False: raise CanGatewayError("MQTT CAN publish confirmation timed out") + return { + "topic": self.settings.control_topic, + "qos": self.settings.qos, + "retain": False, + "payload": json.loads(raw_payload), + } def _receive_after( self, @@ -782,8 +870,24 @@ class MqttCanBus: self._cursor = cursor self.channel = channel self._initial_frames = deque(initial_frames) + self._debug_command = "" + self._published_messages: list[dict[str, Any]] = [] self._closed = False + @property + def published_messages(self) -> tuple[dict[str, Any], ...]: + """Return this bus view's confirmed MQTT publications in send order.""" + + return tuple(deepcopy(self._published_messages)) + + def set_debug_command(self, command: str | None) -> None: + """Attach semantic debug metadata to subsequent MQTT publications.""" + + value = "" if command is None else str(command).strip() + if len(value) > 256: + raise CanGatewayError("MQTT CAN debug command must contain at most 256 characters") + self._debug_command = value + def send( self, arbitration_id: int, @@ -809,7 +913,8 @@ class MqttCanBus: is_fd=is_fd, bitrate_switch=bitrate_switch, ) - self._transport.publish(frame) + publication = self._transport.publish(frame, command=self._debug_command) + self._published_messages.append(publication) return frame def receive( diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index 722c86a..bd1cbd6 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -359,6 +359,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler): if path.startswith("/api/self-repair/"): self._handle_self_repair_post(path) return + if path.startswith("/api/mode-profiles/"): + self._handle_mode_profile_post(path) + return if path != "/api/requests": self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND) return @@ -519,6 +522,35 @@ class SeatDemoHandler(BaseHTTPRequestHandler): HTTPStatus.BAD_REQUEST, ) + def _handle_mode_profile_post(self, path: str) -> None: + mode_id = path.removeprefix("/api/mode-profiles/").strip() + if not mode_id: + self._json( + {"error": "invalid_request", "message": "模式编号不能为空。"}, + HTTPStatus.BAD_REQUEST, + ) + return + try: + payload = self._read_json_body(max_bytes=16 * 1024) + result = self.service.save_mode_profile_card( + mode_id, + str(payload.get("name", "")), + str(payload.get("instruction", "")), + str(payload.get("source_request_id", "")), + str(payload.get("vehicle_id", "")) or None, + ) + self._json(result, HTTPStatus.OK) + except KeyError: + self._json( + {"error": "not_found", "message": "未找到需要编辑的模式卡片。"}, + HTTPStatus.NOT_FOUND, + ) + except (ValueError, json.JSONDecodeError) as exc: + self._json( + {"error": "invalid_request", "message": str(exc)}, + HTTPStatus.BAD_REQUEST, + ) + def _handle_extension_post(self, path: str) -> None: action_map = { "/api/extension/gscu/learn": "learn", @@ -1234,12 +1266,18 @@ def main() -> None: ) else: print(f"Cabin orchestration demo: http://{args.host}:{args.port}", flush=True) - hardware = SeatDemoHandler.service.hardware_settings + hardware_execution_ready = any( + ( + SeatDemoHandler.service.hardware_settings.configured, + SeatDemoHandler.service.vehicle_can_settings.configured, + SeatDemoHandler.service.vehicle_mqtt_settings.configured, + ) + ) print( "Execution modes: dry_run + simulated_hardware " f"({'enabled' if SeatDemoHandler.service.simulated_hardware_enabled else 'locked'}) " "+ hardware " - f"({'configured' if hardware.configured else 'locked'})", + f"({'configured' if hardware_execution_ready else 'locked'})", flush=True, ) try: diff --git a/src/cognitive_agent/testing/ch1_hil.py b/src/cognitive_agent/testing/ch1_hil.py index 927fc52..06d1050 100644 --- a/src/cognitive_agent/testing/ch1_hil.py +++ b/src/cognitive_agent/testing/ch1_hil.py @@ -70,8 +70,6 @@ MODULE_ENDPOINTS = { RETIRED_DOOR_COMMANDS = { "REJECT-VEHICLE-LOCK": "door vehicle lock", "REJECT-VEHICLE-UNLOCK": "door vehicle unlock", - "REJECT-REAR-LOCK": "door rear lock", - "REJECT-REAR-UNLOCK": "door rear unlock", } diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 4f88644..b073e08 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -175,6 +175,108 @@ operations: finally: self.service.mode_profiles = original_registry + def test_mode_card_editor_endpoint_persists_generated_can_flow(self) -> None: + original_registry = self.service.mode_profiles + original_artifact_root = self.service.artifact_root + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + preset_root = base / "presets" + user_root = base / "user-modes" + preset = preset_root / "vehicles" / "denza_n7" / "office.yaml" + preset.parent.mkdir(parents=True) + preset.write_text( + """ +id: office +name: 办公模式 +scope: + vehicle_ids: [denza_n7] +operations: +- domain: climate + capability: climate.set_temperature + target_scope: passenger + parameters: + temperature_c: 24 +""".strip() + + "\n", + encoding="utf-8", + ) + request_id = "req-mode-editor-api-001" + instruction = "副驾空调温度设置为24度" + artifact_root = base / "runs" + request_dir = artifact_root / request_id + request_dir.mkdir(parents=True) + result = { + "request_id": request_id, + "utterance": instruction, + "execution_mode": "dry_run", + "control_transport": "wireless_mqtt", + "function_configs": { + "native_climate": { + "spec": { + "domain": "climate", + "operations": [ + { + "id": "passenger_temperature", + "capability": "climate.set_temperature", + "target": {"zone": "passenger"}, + "parameters": {"temperature_c": 24}, + } + ], + } + } + }, + "command_plans": { + "native_climate": { + "spec": { + "commands": [ + { + "message": "Native_0x1DE", + "signals": { + "frame_id": "0x1DE", + "data_hex": "00 00 00 00 00 18 00 00", + }, + } + ] + } + } + }, + "execution_report": {"spec": {"results": []}}, + } + (request_dir / "result.json").write_text( + json.dumps(result, ensure_ascii=False), encoding="utf-8" + ) + self.service.mode_profiles = ModeProfileRegistry( + preset_root, user_root=user_root + ) + self.service.artifact_root = artifact_root + try: + request = urllib.request.Request( + f"{self.base_url}/api/mode-profiles/office", + method="POST", + headers={"Content-Type": "application/json"}, + data=json.dumps( + { + "name": "专注办公模式", + "instruction": instruction, + "source_request_id": request_id, + "vehicle_id": "denza_n7", + }, + ensure_ascii=False, + ).encode("utf-8"), + ) + with self.opener.open(request, timeout=3) as response: + payload = json.loads(response.read().decode("utf-8")) + 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.assertTrue( + (user_root / "vehicles" / "denza_n7" / "user" / "office.yaml").is_file() + ) + finally: + self.service.mode_profiles = original_registry + self.service.artifact_root = original_artifact_root + def test_head_unit_display_sync_tracks_tasks_and_usb_catalog(self) -> None: task_request = urllib.request.Request( f"{self.base_url}/api/requests", @@ -230,18 +332,18 @@ 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-90", timeout=3) as response: + with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-101", 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-8", timeout=3 + 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=sync-layout-27", timeout=3 + f"{self.base_url}/console-v2.js?v=sync-layout-41", timeout=3 ) as response: console_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/console-v2.css?v=sync-layout-27", timeout=3 + f"{self.base_url}/console-v2.css?v=sync-layout-40", timeout=3 ) as response: console_styles = response.read().decode("utf-8") with self.opener.open( @@ -263,7 +365,7 @@ operations: with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response: runtime = json.loads(response.read().decode("utf-8")) - self.assertIn("Cognitive Agent", html) + self.assertIn("511工程-智能座舱", html) self.assertIn('id="seatTwinCanvas"', html) self.assertIn('data-vehicle-model="byd-tang-2024-reference"', html) self.assertIn("整车孪生界面", html) @@ -276,12 +378,15 @@ operations: 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-90"', html) - self.assertIn('href="/styles.css?v=dual-seat-10"', html) - self.assertIn('src="/gesture-control.js?v=android-pad-8"', html) + self.assertIn('src="/app.js?v=dual-seat-101"', html) + self.assertIn('href="/styles.css?v=dual-seat-16"', 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=sync-layout-27"', html) - self.assertIn('href="/console-v2.css?v=sync-layout-27"', html) + self.assertIn('src="/console-v2.js?v=sync-layout-41"', html) + self.assertIn('href="/console-v2.css?v=sync-layout-40"', html) + self.assertIn('', html) + self.assertIn('id="v2BootScreen"', html) + self.assertIn('正在加载车机控制与实时状态', html) self.assertIn('src="/self-extension.js?v=sync-layout-26"', html) self.assertIn('href="/self-extension.css?v=sync-layout-26"', html) self.assertNotIn("self-calibration", html) @@ -291,7 +396,7 @@ operations: self.assertIn('aria-label="执行车舱控制指令"', html) self.assertNotIn('"admin"', console_script) self.assertNotIn("v2RoleSelect", console_script) - self.assertIn('id="v2SidebarToggle"', console_script) + self.assertIn('sidebarToggle.id = "v2SidebarToggle"', console_script) self.assertIn('id = "v2ModuleSwitcher"', console_script) self.assertIn( 'panelHeading.classList.add("v2-operation-heading", "v2-module-heading")', @@ -316,28 +421,33 @@ operations: ) self.assertIn('511工程-智能座舱', console_script) self.assertIn('网页控制链路', console_script) - self.assertIn('Hi,user', console_script) self.assertIn('["self-orchestration", "self-extension"]', console_script) self.assertNotIn('label: "自诊断"', console_script) - self.assertIn('手势模型', console_script) - self.assertIn('id="v2GestureModelValue">MediaPipe GR', console_script) - self.assertIn('id="v2GestureModelNote">Tasks Vision · 0.10.14', console_script) - self.assertIn('配置信息', console_script) - self.assertIn('state.id = "v2VehicleState"', console_script) - self.assertIn('fetch("/api/extension/health"', console_script) + self.assertNotIn('runtimeControls.id = "v2RuntimeControls"', console_script) + self.assertIn('function prepareRuntimeControls(originalForm)', console_script) + self.assertIn('executionField.hidden = false', console_script) + self.assertIn('insertVisibleControl(executionField, "v2-sync-execution-field")', console_script) + self.assertIn('insertVisibleControl(simulationFaultField, "v2-sync-simulation-fault-field")', console_script) + self.assertNotIn('class="v2-profile"', console_script) + self.assertNotIn('class="v2-control-switch"', console_script) + self.assertNotIn('class="v2-config-stack"', console_script) + self.assertNotIn('id="v2GestureModelValue"', console_script) + self.assertNotIn('id="v2VehicleState"', console_script) self.assertIn('actionRail.append(cameraToggle)', console_script) - self.assertIn("function enhanceConfiguredDropdowns()", console_script) + self.assertNotIn("function enhanceConfiguredDropdowns()", console_script) self.assertIn("function updateModeEdgeHints()", console_script) self.assertIn('classList.toggle("v2-twin-active", definition.index === 0)', console_script) self.assertIn('label: "执行结果与硬件回读"', console_script) - self.assertIn("function requestUserSafeExecutionMode()", console_script) - self.assertIn('const dryRunButton = $(\'[data-execution-mode="dry_run"]\')', console_script) - self.assertIn('if (!dryRunButton.classList.contains("active")) dryRunButton.click()', console_script) - self.assertIn('userTargetText.textContent = "执行目标:虚拟 CAN"', console_script) - self.assertIn("user 固定使用虚拟 CAN 安全执行环境", console_script) - self.assertNotIn("user 演示固定使用真实台架", console_script) + self.assertIn("function keepRunButtonAvailable()", console_script) + self.assertIn('window.setTimeout(() => $("#v2BootScreen")?.remove(), 220)', console_script) + self.assertNotIn("function requestUserSafeExecutionMode()", console_script) + self.assertNotIn('dryRunButton.click()', console_script) + 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('grid-template-columns: clamp(245px, 18.5vw, 310px)', console_styles) - self.assertIn('.v2-control-switch {', console_styles) + self.assertNotIn('.v2-control-switch {', console_styles) self.assertIn('.v2-module-switcher {', console_styles) self.assertIn('grid-template-columns: repeat(2, minmax(0, 1fr))', console_styles) self.assertIn( @@ -364,7 +474,7 @@ operations: self.assertIn('width: clamp(48px, 45%, 72px)', console_styles) self.assertIn('font-weight: 800', console_styles) self.assertIn('white-space: nowrap', console_styles) - self.assertIn('.v2-gesture-model-field', console_styles) + self.assertNotIn('.v2-gesture-model-field', console_styles) self.assertIn('.v2-inline-gesture .v2-camera-toggle', console_styles) self.assertIn('.v2-inline-gesture .v2-gesture-action-rail', console_styles) self.assertIn('grid-template-rows: 42px 10px 42px', console_styles) @@ -373,9 +483,8 @@ operations: self.assertIn('.v2-inline-gesture #gestureStopButton', console_styles) self.assertIn('.v2-inline-gesture .v2-gesture-result-stack', console_styles) self.assertIn('.v2-inline-gesture .v2-gesture-execution-slot', console_styles) - self.assertIn('grid-template-columns: 1fr;', console_styles) self.assertIn('.v2-page-rail', console_styles) - self.assertIn('.v2-dropdown-menu', console_styles) + self.assertNotIn('.v2-dropdown-menu', console_styles) self.assertIn('.v2-edge-fade-left', console_styles) self.assertIn('.v2-edge-fade-right', console_styles) self.assertIn('.mode-group-divider', console_styles) @@ -385,8 +494,8 @@ operations: self.assertIn('.joint-seat-panel .thermal-states', console_styles) self.assertIn('.joint-twin-shell .domain-readouts', console_styles) self.assertIn('grid-template-columns: repeat(2, minmax(0, 1fr))', console_styles) - self.assertIn('.v2-config-section > .v2-field-caption', console_styles) - self.assertIn('.console-v2 .v2-config-card {', console_styles) + 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) @@ -406,8 +515,14 @@ operations: self.assertIn('data-control-transport="wireless_mqtt"', html) self.assertIn('control_transport: state.controlTransport', app_script) self.assertIn('const transportField = $("#vehicleTransportField", originalForm)', console_script) - self.assertIn('configStack.append(transportField)', console_script) + self.assertNotIn('runtimeControls.append(transportField)', console_script) + self.assertIn('insertVisibleControl(transportField, "v2-sync-transport-field")', console_script) + self.assertIn('.v2-sync-execution-field .execution-mode-options', console_styles) + self.assertIn('grid-template-columns: repeat(3, minmax(0, 1fr))', console_styles) + self.assertNotIn('class="v2-sync-protocol"', console_script) self.assertIn('.console-v2 .v2-transport-field .vehicle-transport-options', console_styles) + self.assertIn('.v2-sync-transport-field .mode-option', console_styles) + self.assertIn('overflow-wrap: anywhere', console_styles) self.assertIn('button.disabled = false', app_script) self.assertIn('button.setAttribute("aria-pressed", String(selected))', app_script) self.assertNotIn( @@ -497,7 +612,7 @@ operations: self.assertIn('client_type: "web_console"', app_script) self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script) - self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v179"', service_worker) + self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v202"', service_worker) self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker) self.assertIn(".then(() => self.skipWaiting())", service_worker) self.assertNotIn("const cached = await caches.match(request)", service_worker) @@ -544,6 +659,13 @@ operations: 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.assertIn('id="saveModeSource"', html) self.assertNotIn("主驾 24℃", html) populate_modes = app_script[ @@ -568,6 +690,17 @@ operations: self.assertIn('deleteUserModeProfile(deleteButton.dataset.modeDelete)', app_script) self.assertIn('method: "DELETE"', app_script) self.assertIn('deleteButton.dataset.modeDelete = mode.id', app_script) + self.assertIn('editButton.dataset.modeEdit = mode.id', app_script) + self.assertIn("function openModeEditor", app_script) + self.assertIn("function executeModeEditorInstruction", app_script) + self.assertIn("function completeModeEditor", app_script) + self.assertIn("function modeEditorReadableCommands", app_script) + self.assertIn("function modeEditorMessagesForOperation", app_script) + self.assertIn("function modeEditorAppendCommandCard", app_script) + self.assertIn('title.textContent = flow?.control_transport === "wireless_mqtt" ? "MQTT 消息序列"', app_script) + 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) @@ -624,7 +757,10 @@ operations: self.assertNotIn('id="transmittanceValue"', html) self.assertNotIn('id="roofMotorState"', html) self.assertIn('id="ambientBrightness"', html) - self.assertIn("坐垫脚托夹角133度", 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.assertEqual(runtime["domain"], "cabin") self.assertEqual( @@ -632,6 +768,7 @@ operations: ( runtime["hardware"]["configured"] or runtime["vehicle_can"]["configured"] + or runtime["vehicle_mqtt"]["configured"] ), ) self.assertEqual(runtime["hardware"]["supported_targets"], ["front_right"]) @@ -686,11 +823,13 @@ operations: for item in runtime["capabilities"] if item["name"].startswith("seat.") and not item["name"].startswith("seat.set_driver_") + and item["name"] != "seat.recall_driver_memory_native" ] driver_seat_capabilities = [ item for item in runtime["capabilities"] if item["name"].startswith("seat.set_driver_") + or item["name"] == "seat.recall_driver_memory_native" ] self.assertTrue(passenger_seat_capabilities) self.assertTrue( @@ -713,7 +852,7 @@ operations: ) as response: extension_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/app.js?v=dual-seat-90", timeout=3 + f"{self.base_url}/app.js?v=dual-seat-101", timeout=3 ) as response: app_script = response.read().decode("utf-8") @@ -1152,7 +1291,7 @@ operations: headers={"Content-Type": "application/json"}, data=json.dumps( { - "utterance": "打开副驾座椅通风", + "utterance": "打开副驾座椅通风,同时播放音乐", "provider": "openai", "conversation_id": "conv-e2e-gpt-fallback", "execution_mode": "dry_run", diff --git a/tests/fixtures/ch1_vehicle_door_smoke_hil_suite.json b/tests/fixtures/ch1_vehicle_door_smoke_hil_suite.json index eb21afc..ffe28b5 100644 --- a/tests/fixtures/ch1_vehicle_door_smoke_hil_suite.json +++ b/tests/fixtures/ch1_vehicle_door_smoke_hil_suite.json @@ -1,11 +1,11 @@ { "extends": "ch1_vehicle_frontend_hil_suite.json", "suite": "ch1-vehicle-door-rejection-contract", - "description": "Offline-only fail-closed contract for retired CH1 door-lock commands. It inherits the complete non-door catalog, safety policy, rejection matrix, and nine-step cleanup baseline from the full front-end suite.", + "description": "Offline-only fail-closed contract for unsupported whole-vehicle CH1 door-lock commands. Rear-door 0x32B candidates remain contract-only in the inherited catalog and are not live cases in this suite.", "rejection_only": true, "smoke_scope": { "retired_candidate_command_frame_id": "0x496", - "retirement_reason": "Physical lock verification failed; cleanup succeeded, so no door-lock command remains eligible for live HIL.", + "retirement_reason": "Whole-vehicle 0x496 verification failed; the distinct rear-only 0x32B command remains an offline contract candidate until current-vehicle HIL evidence is recorded.", "live_execution_permitted_by_fixture_alone": false }, "modules": { @@ -21,8 +21,6 @@ "REJECT-RAW-CAN", "REJECT-VEHICLE-LOCK", "REJECT-VEHICLE-UNLOCK", - "REJECT-REAR-LOCK", - "REJECT-REAR-UNLOCK", "REJECT-WALL-LIGHT", "REJECT-READING-ZONE", "REJECT-AMBIENT-SCENE", @@ -41,8 +39,6 @@ "REJECT-RAW-CAN", "REJECT-VEHICLE-LOCK", "REJECT-VEHICLE-UNLOCK", - "REJECT-REAR-LOCK", - "REJECT-REAR-UNLOCK", "REJECT-WALL-LIGHT", "REJECT-READING-ZONE", "REJECT-AMBIENT-SCENE", diff --git a/tests/fixtures/ch1_vehicle_frontend_hil_suite.json b/tests/fixtures/ch1_vehicle_frontend_hil_suite.json index 2603c80..d411ff3 100644 --- a/tests/fixtures/ch1_vehicle_frontend_hil_suite.json +++ b/tests/fixtures/ch1_vehicle_frontend_hil_suite.json @@ -58,6 +58,7 @@ "case_ids": [ "CLIMATE-POWER-ON", "CLIMATE-POWER-OFF", "CLIMATE-FAN-3", "CLIMATE-MODE-DEFROST", "CLIMATE-TEMP-DRIVER-24", "CLIMATE-CONTROL-AUTO", + "DOOR-REAR-LOCK", "DOOR-REAR-UNLOCK", "WINDOW-FRONT-LEFT-10", "WINDOW-FRONT-LEFT-50", "WINDOW-FRONT-LEFT-0", "OUTLET-DRIVER-LEFT-CENTER", "SEAT-DRIVER-VENT-LOW", "SEAT-DRIVER-VENT-HIGH", "SEAT-DRIVER-VENT-OFF", @@ -75,7 +76,6 @@ "REJECT-CH0", "REJECT-PASSENGER", "REJECT-FRONT-RIGHT", "REJECT-T2G", "REJECT-IAR", "REJECT-RAW-CAN", "REJECT-VEHICLE-LOCK", "REJECT-VEHICLE-UNLOCK", - "REJECT-REAR-LOCK", "REJECT-REAR-UNLOCK", "REJECT-WALL-LIGHT", "REJECT-READING-ZONE", "REJECT-AMBIENT-SCENE", "REJECT-AMBIENT-RGB" ] }, @@ -84,6 +84,7 @@ "case_ids": [ "CLIMATE-POWER-ON", "CLIMATE-POWER-OFF", "CLIMATE-FAN-3", "CLIMATE-MODE-DEFROST", "CLIMATE-TEMP-DRIVER-24", "CLIMATE-CONTROL-AUTO", + "DOOR-REAR-LOCK", "DOOR-REAR-UNLOCK", "WINDOW-FRONT-LEFT-10", "WINDOW-FRONT-LEFT-50", "WINDOW-FRONT-LEFT-0", "OUTLET-DRIVER-LEFT-CENTER", "SEAT-DRIVER-VENT-LOW", "SEAT-DRIVER-VENT-HIGH", "SEAT-DRIVER-VENT-OFF", @@ -101,7 +102,6 @@ "REJECT-CH0", "REJECT-PASSENGER", "REJECT-FRONT-RIGHT", "REJECT-T2G", "REJECT-IAR", "REJECT-RAW-CAN", "REJECT-VEHICLE-LOCK", "REJECT-VEHICLE-UNLOCK", - "REJECT-REAR-LOCK", "REJECT-REAR-UNLOCK", "REJECT-WALL-LIGHT", "REJECT-READING-ZONE", "REJECT-AMBIENT-SCENE", "REJECT-AMBIENT-RGB" ] } @@ -131,6 +131,14 @@ "id": "CLIMATE-CONTROL-AUTO", "command": "ac control auto", "expected": {"config_key": "native_climate", "domain": "climate", "capability": "climate.set_control_mode", "target": "vehicle", "frame_ids": ["0x1DE"], "feedback_ids": ["0x404"], "state_assertions": [{"path": "domain_state.climate.control_mode", "equals": "auto"}]} }, + { + "id": "DOOR-REAR-LOCK", "command": "door rear lock", "hil": false, + "expected": {"config_key": "native_door", "domain": "door", "capability": "door.set_rear_lock", "target": "rear", "frame_ids": ["0x32B"], "feedback_ids": ["0x407"], "state_assertions": [{"path": "domain_state.door.locks.rear_left.state", "equals": "lock"}, {"path": "domain_state.door.locks.rear_right.state", "equals": "lock"}]} + }, + { + "id": "DOOR-REAR-UNLOCK", "command": "door rear unlock", "hil": false, + "expected": {"config_key": "native_door", "domain": "door", "capability": "door.set_rear_lock", "target": "rear", "frame_ids": ["0x32B"], "feedback_ids": ["0x407"], "state_assertions": [{"path": "domain_state.door.locks.rear_left.state", "equals": "unlock"}, {"path": "domain_state.door.locks.rear_right.state", "equals": "unlock"}]} + }, { "id": "WINDOW-FRONT-LEFT-10", "command": "window front-left position 10", "action_timeout_seconds": 20, "expected": {"config_key": "native_window", "domain": "window", "capability": "window.set_position", "target": "front_left", "frame_ids": ["0x431"], "feedback_ids": ["0x388"], "state_assertions": [{"path": "domain_state.window.positions.front_left.position_pct", "equals": 10, "tolerance": 3}]} @@ -243,8 +251,6 @@ {"id": "REJECT-RAW-CAN", "command": "raw can id 0x431 payload 00 00 00 00 00 00 01 00", "expected_category": "safety_rejection"}, {"id": "REJECT-VEHICLE-LOCK", "command": "door vehicle lock", "expected_category": "safety_rejection"}, {"id": "REJECT-VEHICLE-UNLOCK", "command": "door vehicle unlock", "expected_category": "safety_rejection"}, - {"id": "REJECT-REAR-LOCK", "command": "door rear lock", "expected_category": "safety_rejection"}, - {"id": "REJECT-REAR-UNLOCK", "command": "door rear unlock", "expected_category": "safety_rejection"}, {"id": "REJECT-WALL-LIGHT", "command": "light wall on", "expected_category": "safety_rejection"}, {"id": "REJECT-READING-ZONE", "command": "light reading front-left on", "expected_category": "safety_rejection"}, {"id": "REJECT-AMBIENT-SCENE", "command": "light ambient scene cinema", "expected_category": "safety_rejection"}, diff --git a/tests/integration/test_ch1_vehicle_hil_contract.py b/tests/integration/test_ch1_vehicle_hil_contract.py index f92799c..6d759fc 100644 --- a/tests/integration/test_ch1_vehicle_hil_contract.py +++ b/tests/integration/test_ch1_vehicle_hil_contract.py @@ -231,9 +231,9 @@ class Ch1VehicleHilContractTests(unittest.TestCase): orchestration = suite_cases(self.suite, "self_orchestration") extension = suite_cases(self.suite, "self_extension") self.assertEqual([case["id"] for case in orchestration], [case["id"] for case in extension]) - self.assertEqual(len(orchestration), 43) + self.assertEqual(len(orchestration), 45) self.assertEqual( - len(suite_rejections(self.suite, "self_orchestration")), 14 + len(suite_rejections(self.suite, "self_orchestration")), 12 ) self.assertEqual(sum(case.get("hil", True) for case in orchestration), 30) self.assertEqual(len(self.suite["cleanup"]), 9) @@ -271,8 +271,6 @@ class Ch1VehicleHilContractTests(unittest.TestCase): retired = { "REJECT-VEHICLE-LOCK": "door vehicle lock", "REJECT-VEHICLE-UNLOCK": "door vehicle unlock", - "REJECT-REAR-LOCK": "door rear lock", - "REJECT-REAR-UNLOCK": "door rear unlock", } positive_commands = {case["command"] for case in self.suite["case_catalog"]} rejection_by_id = { @@ -400,8 +398,6 @@ class Ch1VehicleHilContractTests(unittest.TestCase): "REJECT-RAW-CAN", "REJECT-VEHICLE-LOCK", "REJECT-VEHICLE-UNLOCK", - "REJECT-REAR-LOCK", - "REJECT-REAR-UNLOCK", "REJECT-WALL-LIGHT", "REJECT-READING-ZONE", "REJECT-AMBIENT-SCENE", @@ -657,9 +653,9 @@ class Ch1VehicleHilContractTests(unittest.TestCase): hardware_access_bypass=True, ) - interpret.assert_called_once() + interpret.assert_not_called() self.assertEqual(vehicle_service.process.call_args.args[1], "openai") - self.assertEqual(response["provider_used"], "openai:gpt-5.6-sol") + self.assertEqual(response["provider_used"], "local_rules") self.assertTrue(response["success"]) self.assertEqual(len(response["operation_results"]), 1) self.assertEqual(len(captured), 1) @@ -742,7 +738,9 @@ class Ch1VehicleHilContractTests(unittest.TestCase): interpret.assert_called_once() self.assertEqual(vehicle_service.process.call_args.args[1], "openai") - self.assertEqual(response["provider_used"], "openai:gpt-5.6-sol") + self.assertEqual( + response["provider_used"], "local_rules+openai:gpt-5.6-sol" + ) self.assertTrue(response["success"]) self.assertEqual(len(response["operation_results"]), 2) self.assertEqual(len(captured), 1) @@ -758,8 +756,6 @@ class Ch1VehicleHilContractTests(unittest.TestCase): retired_door_ids = { "REJECT-VEHICLE-LOCK", "REJECT-VEHICLE-UNLOCK", - "REJECT-REAR-LOCK", - "REJECT-REAR-UNLOCK", } for index, case in enumerate( suite_rejections(self.suite, "self_orchestration") @@ -1081,7 +1077,7 @@ class Ch1VehicleHilContractTests(unittest.TestCase): report["summary"]["offline_rejection_cases"], 2 * len(fixture["rejection_catalog"]), ) - self.assertEqual(len(fixture["rejection_catalog"]), 14) + self.assertEqual(len(fixture["rejection_catalog"]), 12) self.assertEqual( [(path, method) for path, method, _ in api.calls], [("/api/runtime", "GET")], diff --git a/tests/integration/test_multidomain_service.py b/tests/integration/test_multidomain_service.py index 4021923..1173abe 100755 --- a/tests/integration/test_multidomain_service.py +++ b/tests/integration/test_multidomain_service.py @@ -510,6 +510,88 @@ class MultiDomainServiceTests(unittest.TestCase): [], ) + def test_mode_card_editor_persists_only_server_generated_command_flow(self) -> None: + mode_root = Path(self.temporary_directory.name) / "mode-card-presets" + user_root = Path(self.temporary_directory.name) / "mode-card-user" + preset = mode_root / "vehicles" / "denza_n7" / "office.yaml" + preset.parent.mkdir(parents=True) + preset.write_text( + """ +id: office +name: 办公模式 +icon: briefcase +scope: + vehicle_ids: [denza_n7] +operations: +- domain: climate + capability: climate.set_temperature + target_scope: passenger + parameters: + temperature_c: 24 +""".strip() + + "\n", + encoding="utf-8", + ) + self.service.mode_profiles = ModeProfileRegistry( + mode_root, user_root=user_root + ) + model_output = { + "detected_domains": ["climate"], + "requests": [ + { + "domain": "climate", + "capability": "climate.set_temperature", + "target_scope": "passenger", + "operation": "set", + "parameters": [{"name": "temperature_c", "value": 24}], + "reason": "办公温度。", + } + ], + "ambiguities": [], + "decision_summary": ["副驾温度设置为24度。"], + "confidence": 0.97, + } + instruction = "副驾空调温度设置为24度" + with patch.object(self.service.openai, "interpret", return_value=model_output): + generated = self.service.process( + instruction, + "openai", + "conv-mode-card-editor", + vehicle_id="denza_n7", + ) + + saved = self.service.save_mode_profile_card( + "office", + "专注办公模式", + instruction, + generated["request_id"], + "denza_n7", + ) + + self.assertEqual(saved["status"], "succeeded") + self.assertEqual(saved["storage"], "persistent_yaml") + self.assertEqual(saved["mode_profile"]["origin"], "customized") + self.assertEqual(saved["mode_profile"]["instruction"], instruction) + self.assertTrue(saved["mode_profile"]["command_flow"]["plans"]) + self.assertTrue( + saved["mode_profile"]["command_flow"]["function_configs"] + ) + reloaded = ModeProfileRegistry(mode_root, user_root=user_root) + persisted = next( + item + for item in reloaded.public_modes("denza_n7") + if item["id"] == "office" + ) + self.assertEqual(persisted["name"], "专注办公模式") + with self.assertRaisesRegex(ValueError, "重新点击执行"): + self.service.save_mode_profile_card( + "office", + "专注办公模式", + "副驾空调温度设置为25度", + generated["request_id"], + "denza_n7", + ) + def test_build_named_mode_executes_and_persists_in_one_turn(self) -> None: mode_root = Path(self.temporary_directory.name) / "modes" self.service.mode_profiles = ModeProfileRegistry(mode_root) diff --git a/tests/integration/test_seat_frontend_input_coverage.py b/tests/integration/test_seat_frontend_input_coverage.py index f5615b7..83f6f7f 100644 --- a/tests/integration/test_seat_frontend_input_coverage.py +++ b/tests/integration/test_seat_frontend_input_coverage.py @@ -46,7 +46,12 @@ class SeatFrontendInputCoverageTests(unittest.TestCase): cls.temporary_directory = tempfile.TemporaryDirectory() with patch.dict( os.environ, - {"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false"}, + { + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false", + "COGNITIVE_AGENT_MODE_PROFILE_ROOT": str( + Path(cls.temporary_directory.name) / "mode-profiles" + ), + }, clear=False, ): cls.service = SeatDemoService(REPOSITORY_ROOT) diff --git a/tests/unit/test_ch1_default_targets.py b/tests/unit/test_ch1_default_targets.py index 672b5c8..3e4a49e 100644 --- a/tests/unit/test_ch1_default_targets.py +++ b/tests/unit/test_ch1_default_targets.py @@ -266,7 +266,7 @@ class Ch1DefaultTargetTests(unittest.TestCase): self.assertEqual(seat["target_scope"], "driver") self.assertEqual(explicit_passenger["target_scope"], "passenger") - def test_gpt_provider_is_preserved_while_wrong_model_targets_are_replaced(self) -> None: + def test_local_rules_skip_gpt_and_apply_safe_ch1_defaults(self) -> None: cases = ( ( "车窗开启一半", @@ -336,8 +336,8 @@ class Ch1DefaultTargetTests(unittest.TestCase): conversation_id=f"conv-ch1-default-openai-{index}", ) - interpret.assert_called_once() - self.assertEqual(result["provider_used"], "openai:gpt-5.6-sol") + interpret.assert_not_called() + self.assertEqual(result["provider_used"], "local_rules") self.assertEqual(result["status"], "succeeded") self.assertEqual(set(result["function_configs"]), {config_key}) self.assertNotIn("seat", result["function_configs"]) diff --git a/tests/unit/test_driver_seat_native_can.py b/tests/unit/test_driver_seat_native_can.py index 1f11c18..4ec28a7 100644 --- a/tests/unit/test_driver_seat_native_can.py +++ b/tests/unit/test_driver_seat_native_can.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import os +import tempfile import unittest from pathlib import Path from unittest.mock import patch @@ -109,6 +111,8 @@ class DriverSeatNativeCanTests(unittest.TestCase): "seat.set_driver_heating_native", "seat.set_driver_massage_native", "seat.set_driver_axis_native", + "seat.set_driver_position_native", + "seat.recall_driver_memory_native", }, ) for capability in capabilities.values(): @@ -151,6 +155,16 @@ class DriverSeatNativeCanTests(unittest.TestCase): {"axis": "height", "direction": "stop", "duration_ms": 500}, "driver", ), + "seat driver position 3": ( + "seat.set_driver_position_native", + {"slot": 3}, + "driver", + ), + "seat driver memory recall 2": ( + "seat.recall_driver_memory_native", + {"slot": 2}, + "driver", + ), } for utterance, expected in cases.items(): with self.subTest(utterance=utterance): @@ -196,6 +210,10 @@ class DriverSeatNativeCanTests(unittest.TestCase): "seat.set_driver_axis_native", {"axis": "height", "direction": "up", "duration_ms": 500}, ), + "主驾座椅调整到记忆位置3": ( + "seat.recall_driver_memory_native", + {"slot": 3}, + ), } for utterance, expected in cases.items(): with self.subTest(utterance=utterance): @@ -356,6 +374,142 @@ class DriverSeatNativeCanTests(unittest.TestCase): self.assertEqual(command["repeat"], {"count": 3, "period_ms": 100}) self.assertNotIn("special_handler", command["signals"]) + def test_memory_aliases_compile_the_same_learned_twenty_frame_sequence(self) -> None: + commands = [] + for capability in ( + "seat.set_driver_position_native", + "seat.recall_driver_memory_native", + ): + with self.subTest(capability=capability): + _, plan = self._compile(capability, {"slot": 3}) + command = plan["spec"]["commands"][0] + commands.append(command) + self.assertEqual(command["signals"]["frame_id"], "0x4C1") + self.assertEqual( + command["signals"]["special_handler"], + "driver_seat_memory_recall", + ) + special = json.loads(command["signals"]["special_parameters"]) + self.assertEqual(len(special["frame_sequence"]), 20) + self.assertEqual( + special["frame_sequence"][0::2], + ["16 9A 66 3C 76 04 06 00"] * 10, + ) + self.assertEqual( + special["frame_sequence"][1::2], + ["15 02 C6 3E 2A C8 FF FF"] * 10, + ) + self.assertEqual(special["period_ms"], 50) + public = self.runtime.command_frame_public(command) + self.assertEqual(public["sequence"]["frame_count"], 20) + self.assertTrue( + public["sequence"]["requires_fresh_initialized_0x494"] + ) + self.assertEqual( + commands[0]["signals"]["special_parameters"], + commands[1]["signals"]["special_parameters"], + ) + + def test_memory_executor_requires_initialized_status_and_alternates_frames(self) -> None: + config, plan = self._compile( + "seat.recall_driver_memory_native", {"slot": 2} + ) + initialized = CanFrame( + 0x494, + bytes((0, 0, 0, 0, 0, 0, 0, 0x3C)), + channel=1, + is_fd=True, + bitrate_switch=True, + ) + bus = _FakeBus([initialized, initialized]) + with patch("cognitive_agent.application.native_can_runtime.time.sleep"): + report = NativeCanExecutor( + lambda: bus, feedback_timeout_ms=1, feedback_required=False + ).execute(self.runtime, plan, config) + self.assertEqual(report["spec"]["status"], "succeeded") + self.assertEqual(len(bus.sent), 20) + self.assertTrue(all(frame.is_fd and frame.bitrate_switch for frame in bus.sent)) + self.assertEqual( + [frame.data[0] for frame in bus.sent], [0x16, 0x15] * 10 + ) + self.assertEqual( + [frame.data for frame in bus.sent[0::2]], + [bytes.fromhex("16 9A 66 3C 76 04 06 00")] * 10, + ) + self.assertEqual( + [frame.data for frame in bus.sent[1::2]], + [bytes.fromhex("15 02 A6 AE 00 C8 FF FF")] * 10, + ) + telemetry = report["spec"]["results"][0]["telemetry"] + self.assertFalse(telemetry["physical_effect_confirmed"]) + self.assertEqual(len(telemetry["sent_frames"]), 20) + + uninitialized_bus = _FakeBus( + [CanFrame(0x494, bytes(8), channel=1, is_fd=True)] + ) + rejected = NativeCanExecutor( + lambda: uninitialized_bus, + feedback_timeout_ms=1, + feedback_required=False, + ).execute(self.runtime, plan, config) + self.assertEqual(rejected["spec"]["status"], "failed") + self.assertFalse(uninitialized_bus.sent) + + def test_memory_sequence_stops_immediately_on_send_failure(self) -> None: + config, plan = self._compile( + "seat.set_driver_position_native", {"slot": 1} + ) + initialized = CanFrame( + 0x494, bytes((0, 0, 0, 0, 0, 0, 0, 0x3C)), channel=1 + ) + bus = _FakeBus([initialized], fail_on_calls={4}) + with patch("cognitive_agent.application.native_can_runtime.time.sleep"): + report = NativeCanExecutor( + lambda: bus, feedback_timeout_ms=1, feedback_required=False + ).execute(self.runtime, plan, config) + self.assertEqual(report["spec"]["status"], "failed") + self.assertEqual(bus.send_calls, 4) + self.assertEqual(len(bus.sent), 3) + + def test_memory_compile_rejects_missing_or_malformed_learned_slots(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "driver_memory.json" + path.write_text( + json.dumps( + { + "version": 1, + "positions": { + "1": { + "payload_15": "15 00", + "payload_16": "16 00 00 00 00 00 00 00", + } + }, + } + ), + encoding="utf-8", + ) + with patch.dict( + os.environ, + {"COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH": str(path)}, + ): + config = self.runtime.build_function_config( + "seat", + [_native_action("seat.recall_driver_memory_native", {"slot": 1})], + "req-bad-memory", + "local_rules", + ) + with self.assertRaisesRegex(NativeCanCompileError, "8-byte"): + self.runtime.compile(config) + + missing = self.runtime.build_function_config( + "seat", + [_native_action("seat.recall_driver_memory_native", {"slot": 2})], + "req-missing-memory", + "local_rules", + ) + with self.assertRaisesRegex(NativeCanCompileError, "has not been learned"): + self.runtime.compile(missing) + def test_compiler_fails_closed_for_wrong_seat_or_invalid_parameters(self) -> None: for target in ("passenger", "front_right"): action = _native_action( diff --git a/tests/unit/test_frontend_vehicle_state_sync_contract.py b/tests/unit/test_frontend_vehicle_state_sync_contract.py index 91b47fc..c45a5cb 100644 --- a/tests/unit/test_frontend_vehicle_state_sync_contract.py +++ b/tests/unit/test_frontend_vehicle_state_sync_contract.py @@ -128,18 +128,18 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase): ): self.assertIn(f'{state_path} === "physical_can_ch1"', summary_source) - def test_orchestration_renders_only_the_physical_ch1_patch(self) -> None: + def test_orchestration_renders_only_the_authoritative_ch1_patch(self) -> None: apply_source = function_source( self.app_source, "function applyAuthoritativeVehicleState(payload) {", '\nwindow.addEventListener("cognitive-agent:extension-vehicle-result"', ) self.assertIn( - "const actuatorPatch = physicalCh1StatePatch(payload.actuator_state)", + "const actuatorPatch = authoritativeCh1StatePatch(payload.actuator_state)", apply_source, ) self.assertIn( - "const domainPatch = physicalCh1StatePatch(payload.domain_state)", + "const domainPatch = authoritativeCh1StatePatch(payload.domain_state)", apply_source, ) self.assertIn("renderSeatState(actuatorPatch)", apply_source) @@ -163,13 +163,18 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase): self.assertIn("renderSeatState(result.actuator_state)", ch1_branch) self.assertIn("renderDomainState(result.domain_state)", ch1_branch) - def test_physical_patch_does_not_promote_unmarked_sibling_domains(self) -> None: + def test_authoritative_patch_accepts_wired_and_wireless_without_promoting_siblings(self) -> None: node = shutil.which("node") if node is None: self.skipTest("Node.js is required to execute the frontend patch helper") + source_helper = function_source( + self.app_source, + "function isAuthoritativeCh1Source(value) {", + "\nfunction renderCabinLiveStatus", + ) patch_source = function_source( self.app_source, - "function physicalCh1StatePatch(value) {", + "function authoritativeCh1StatePatch(value) {", "\nfunction isAuthoritativeVehicleStatePayload", ) state = { @@ -178,14 +183,20 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase): "power": "on", }, "roof": {"shade_position_pct": 100}, + "ambient_light": { + "feedback_source": "wireless_mqtt_ch1", + "power": "on", + }, "window": { "positions": {"front_left": {"position_pct": 50}} }, } script = ( + 'const AUTHORITATIVE_CH1_SOURCES = new Set(["physical_can_ch1", "wireless_mqtt_ch1"]);\n' + f"{source_helper}\n" f"{patch_source}\n" f"const input = {json.dumps(state)};\n" - "process.stdout.write(JSON.stringify(physicalCh1StatePatch(input)));" + "process.stdout.write(JSON.stringify(authoritativeCh1StatePatch(input)));" ) completed = subprocess.run( [node, "-e", script], @@ -195,7 +206,10 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase): ) self.assertEqual( json.loads(completed.stdout), - {"climate": state["climate"]}, + { + "climate": state["climate"], + "ambient_light": state["ambient_light"], + }, ) def test_dual_channel_runtime_polls_each_seat_without_aliasing(self) -> None: @@ -249,6 +263,24 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase): self.assertIn("}, physicalStatePollIntervalMs());", self.app_source) self.assertIn("restartPhysicalStatePolling();", self.app_source) + def test_transport_switch_clears_stale_vehicle_values_before_new_feedback(self) -> None: + select_source = function_source( + self.app_source, + "function selectControlTransport(transport) {", + "\nfunction renderHardwareAccess", + ) + reset_source = function_source( + self.app_source, + "function resetVehicleStatusDisplay() {", + "\nfunction mergeSeatActuatorState", + ) + + self.assertIn("resetVehicleStatusDisplay();", select_source) + self.assertIn('setText("#windowState", "UNKNOWN")', reset_source) + self.assertIn('setText("#frontLightState", "UNKNOWN")', reset_source) + self.assertIn('setText("#cabinSummaryDoors", "状态未知")', reset_source) + self.assertIn('meter.dataset.state = "unknown"', reset_source) + def test_vehicle_selection_cannot_replay_unfiltered_state_in_ch1_mode(self) -> None: select_source = function_source( self.app_source, diff --git a/tests/unit/test_mode_profile.py b/tests/unit/test_mode_profile.py index 112b22a..0409319 100644 --- a/tests/unit/test_mode_profile.py +++ b/tests/unit/test_mode_profile.py @@ -356,6 +356,85 @@ operations: [{"name": "transmittance_pct", "value": 20}], ) + def test_mode_card_override_persists_instruction_and_can_command_flow(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "presets" + user_root = Path(directory) / "persistent-user-modes" + preset = root / "vehicles" / "denza_n7" / "office.yaml" + preset.parent.mkdir(parents=True) + preset.write_text( + """ +id: office +name: 办公模式 +icon: briefcase +scope: + vehicle_ids: [denza_n7] +operations: +- domain: climate + capability: climate.set_temperature + target_scope: passenger + parameters: + temperature_c: 24 +""".strip() + + "\n", + encoding="utf-8", + ) + registry = ModeProfileRegistry(root, user_root=user_root) + flow = { + "schema": "cognitive-agent-mode-command-flow/v1", + "source_request_id": "req-mode-editor-001", + "plans": { + "native_climate": { + "spec": { + "commands": [ + { + "message": "Native_0x1DE", + "signals": { + "frame_id": "0x1DE", + "data_hex": "00 00 00 00 18 00 00 00", + }, + } + ] + } + } + }, + } + + registry.save_mode( + "专注办公模式", + [ + { + "domain": "climate", + "capability": "climate.set_temperature", + "target_scope": "passenger", + "parameters": {"temperature_c": 24}, + } + ], + mode_id="office", + vehicle_id="denza_n7", + icon="briefcase", + instruction="副驾空调设置为24度", + command_flow=flow, + ) + + reloaded = ModeProfileRegistry(root, user_root=user_root) + mode = next( + item + for item in reloaded.public_modes("denza_n7") + if item["id"] == "office" + ) + self.assertEqual(mode["name"], "专注办公模式") + self.assertEqual(mode["origin"], "customized") + self.assertTrue(mode["preset_override"]) + self.assertEqual(mode["instruction"], "副驾空调设置为24度") + self.assertEqual( + mode["command_flow"]["source_request_id"], + "req-mode-editor-001", + ) + self.assertTrue( + (user_root / "vehicles" / "denza_n7" / "user" / "office.yaml").is_file() + ) + def test_session_overlay_ignores_stale_repository_user_modes(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) / "profiles" diff --git a/tests/unit/test_mqtt_can_transport.py b/tests/unit/test_mqtt_can_transport.py index b0d0a34..5a96b68 100644 --- a/tests/unit/test_mqtt_can_transport.py +++ b/tests/unit/test_mqtt_can_transport.py @@ -1,11 +1,15 @@ from __future__ import annotations import json +import tempfile import time import unittest +from pathlib import Path from types import SimpleNamespace from unittest.mock import patch +from cognitive_agent.application.native_can_runtime import NativeCanRuntime +from cognitive_agent.application.seat_service import SeatDemoService from cognitive_agent.infrastructure.can.gateway import CanFrame from cognitive_agent.infrastructure.can.hardware import ( HardwareSafetyError, @@ -18,6 +22,9 @@ from cognitive_agent.infrastructure.can.mqtt_transport import ( ) +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] + + class _PublishResult: rc = 0 @@ -72,7 +79,7 @@ def _settings(): "host": "127.0.0.1", "port": 1883, "condition_topic": "n7/n7-headunit/can/state", - "control_topic": "n7/can_control", + "control_topic": "n7/n7-headunit/can/command", "qos": 1, "keepalive_s": 30, "username": None, @@ -86,12 +93,32 @@ def _settings(): "state_configured": True, "backend": "mqtt", "condition_topic": "n7/n7-headunit/can/state", - "control_topic": "n7/can_control", + "control_topic": "n7/n7-headunit/can/command", } return SimpleNamespace(**values) class MqttCanPayloadTests(unittest.TestCase): + def test_default_topics_and_public_contracts_match_headunit(self) -> None: + with patch.dict("os.environ", {}, clear=True): + settings = VehicleMqttSettings.from_env() + info = settings.public_info() + self.assertEqual(settings.condition_topic, "n7/n7-headunit/can/state") + self.assertEqual(settings.control_topic, "n7/n7-headunit/can/command") + self.assertFalse(settings.approval_required) + self.assertEqual(info["state_contract"], "n7-can-signals/v1") + self.assertEqual(info["control_contract"], "n7-headunit-can-command/v1") + + def test_explicit_shared_approval_policy_still_locks_mqtt_control(self) -> None: + with patch.dict( + "os.environ", + {"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "true"}, + clear=True, + ): + settings = VehicleMqttSettings.from_env() + self.assertTrue(settings.approval_required) + self.assertFalse(settings.configured) + def test_accepts_one_canonical_frame_and_common_receive_aliases(self) -> None: canonical = parse_mqtt_can_payload( json.dumps( @@ -172,8 +199,8 @@ class MqttCanPayloadTests(unittest.TestCase): self.assertEqual(frame.arbitration_id, 0x42E) self.assertTrue(frame.is_fd) - def test_accepts_nested_can0_seat_signals_with_can0_default(self) -> None: - frame = parse_mqtt_can_payload( + def test_ignores_legacy_can0_state_outside_seat_ota_wire_protocol(self) -> None: + frames = parse_mqtt_can_payload( json.dumps( { "schema": "n7-can-signals/v1", @@ -188,10 +215,8 @@ class MqttCanPayloadTests(unittest.TestCase): }, } ) - )[0] - self.assertEqual(frame.arbitration_id, 0x302) - self.assertEqual(frame.channel, 0) - self.assertFalse(frame.is_fd) + ) + self.assertEqual(frames, []) def test_translates_seat_ota_info_responses_into_four_position_states(self) -> None: values = { @@ -199,10 +224,6 @@ class MqttCanPayloadTests(unittest.TestCase): 2: (373 << 16) | 266, 3: (917 << 16) | 451, 4: (647 << 16) | 575, - 5: 0x11, - 6: 0x11, - 7: 0x11, - 8: 0x11, } signals = [] for selector, value in values.items(): @@ -243,7 +264,7 @@ class MqttCanPayloadTests(unittest.TestCase): "fd", { "channel": "can0", - "canId": "0x302", + "canId": "0x6A0", "frameType": "fd", "bytes": [0] * 8, }, @@ -252,7 +273,7 @@ class MqttCanPayloadTests(unittest.TestCase): "brs", { "channel": "can0", - "canId": "0x302", + "canId": "0x6A2", "frameType": "classic", "brs": True, "bytes": [0] * 8, @@ -262,7 +283,7 @@ class MqttCanPayloadTests(unittest.TestCase): "dlc", { "channel": "can0", - "canId": "0x302", + "canId": "0x6A2", "frameType": "classic", "bytes": [0] * 7, }, @@ -292,7 +313,7 @@ class MqttCanPayloadTests(unittest.TestCase): ) self.assertEqual( [(frame.channel, frame.arbitration_id) for frame in frames], - [(0, 0x302), (1, 0x342)], + [(1, 0x342)], ) def test_rejects_decoded_state_invalid_channels_and_channel_conflicts(self) -> None: @@ -322,7 +343,7 @@ class MqttCanPayloadTests(unittest.TestCase): host="127.0.0.1", port=1883, condition_topic="n7/n7-headunit/can/state", - control_topic="n7/can_control", + control_topic="n7/n7-headunit/can/command", qos=1, keepalive_s=30, username=None, @@ -377,6 +398,12 @@ class MqttCanTransportTests(unittest.TestCase): for state in snapshot.feedback_states ) ) + self.assertTrue( + all( + state["source"] == "wireless_mqtt_ch1" + for state in snapshot.feedback_states + ) + ) self.assertEqual( self.client.subscriptions, [("n7/n7-headunit/can/state", 1)], @@ -404,6 +431,53 @@ class MqttCanTransportTests(unittest.TestCase): self.assertLess(time.perf_counter() - started, 0.05) self.assertEqual(snapshot.observed_ids, frozenset({0x388})) + def test_vehicle_envelope_four_frames_decode_from_raw_can1_bytes(self) -> None: + self.client.emit( + "n7/n7-headunit/can/state", + { + "schema": "n7-can-signals/v1", + "sequence": 335, + "signals": [ + {"channel": "can1", "canId": frame_id, "frameType": "fd", + "brs": True, "data": data} + for frame_id, data in ( + ("0x388", "00 00 00 02 01 00 59 00"), + ("0x41A", "00 5C 85 08 20 42 C1 FD"), + ("0x42E", "11 64 32 03 90 02 00 C6"), + ("0x3FF", "5B 09 FF 0F 32 61 04 12"), + ) + ], + # This summary is deliberately inconsistent. Only the raw CAN + # bytes above are allowed to update authoritative UI state. + "can1Vehicle": {"parsed": {"speedKph": 123, "gear": 4}}, + }, + ) + + snapshot = self.transport.snapshot() + self.assertEqual( + snapshot.observed_ids, + frozenset({0x388, 0x3FF, 0x41A, 0x42E}), + ) + by_id = {state["frame_id"]: state for state in snapshot.feedback_states} + self.assertEqual( + by_id["0x388"]["domain_state"]["window"]["positions"]["rear_left"]["position_pct"], + 2, + ) + self.assertEqual( + by_id["0x41A"]["domain_state"]["roof"]["shade_position_pct"], + 92, + ) + self.assertIsNone( + by_id["0x42E"]["domain_state"]["ambient_light"]["front_color_index"] + ) + self.assertEqual( + by_id["0x3FF"]["domain_state"]["interior_light"]["ambient_illuminance_lux"], + 510, + ) + self.assertTrue( + all(state["source"] == "wireless_mqtt_ch1" for state in by_id.values()) + ) + def test_stale_safety_signal_cannot_enter_snapshot_or_executor_bus(self) -> None: bus = self.transport.bus(channel=1) self.client.emit( @@ -470,8 +544,9 @@ class MqttCanTransportTests(unittest.TestCase): self.assertGreater(verification["cache_age_ms"], 50_000) self.assertIsNone(bus.receive(0, include_fd=True)) - def test_bus_publishes_only_canonical_frame_contract(self) -> None: + def test_bus_publishes_only_headunit_command_contract(self) -> None: bus = self.transport.bus() + bus.set_debug_command("shade position 20") sent = bus.send( 0x431, bytes.fromhex("10 00 00 00 00 00 00 00"), @@ -482,25 +557,253 @@ class MqttCanTransportTests(unittest.TestCase): self.assertEqual(len(self.client.published), 1) topic, raw_payload, qos, retain = self.client.published[0] payload = json.loads(raw_payload) - self.assertEqual(topic, "n7/can_control") + self.assertEqual(topic, "n7/n7-headunit/can/command") self.assertEqual(qos, 1) self.assertFalse(retain) - self.assertEqual(payload["schema"], MQTT_CAN_SCHEMA) - self.assertEqual(payload["arbitration_id"], 0x431) - self.assertEqual(payload["data_hex"], "10 00 00 00 00 00 00 00") - self.assertTrue(payload["is_fd"]) - self.assertTrue(payload["bitrate_switch"]) + self.assertRegex(payload["requestId"], r"^cognitive-agent-[0-9a-f]{32}$") + self.assertEqual(payload["command"], "shade position 20") + self.assertEqual(payload["channel"], "can1") + self.assertEqual(payload["canId"], "0x431") + self.assertEqual(payload["frameType"], "fd") + self.assertTrue(payload["brs"]) + self.assertFalse(payload["extended"]) + self.assertFalse(payload["remote"]) + self.assertEqual(payload["data"], "10 00 00 00 00 00 00 00") + self.assertEqual(payload["repeat"], 1) + self.assertEqual(payload["intervalMs"], 0) + self.assertEqual(payload["responseTimeoutMs"], 100) + publication = bus.published_messages[0] + self.assertEqual(publication["topic"], topic) + self.assertEqual(publication["qos"], qos) + self.assertEqual(publication["retain"], retain) + self.assertEqual(publication["payload"], payload) + self.assertTrue( + { + "schema", + "arbitration_id", + "frame_id", + "data_hex", + "is_fd", + "bitrate_switch", + }.isdisjoint(payload) + ) + bus.send( + 0x431, + bytes.fromhex("10 00 00 00 00 00 00 00"), + is_fd=True, + bitrate_switch=True, + ) + second_payload = json.loads(self.client.published[1][1]) + self.assertNotEqual(payload["requestId"], second_payload["requestId"]) + with self.assertRaisesRegex(Exception, "FD command requires BRS"): + bus.send(0x431, bytes(8), is_fd=True, bitrate_switch=False) + with self.assertRaisesRegex(Exception, "classic CAN command cannot enable BRS"): + bus.send(0x431, bytes(8), is_fd=False, bitrate_switch=True) with self.assertRaisesRegex(Exception, "approved CAN1 control set"): bus.send(0x555, bytes(8), is_fd=True) - self.assertEqual(len(self.client.published), 1) + with self.assertRaisesRegex(Exception, "at most 256"): + bus.set_debug_command("x" * 257) + self.assertEqual(len(self.client.published), 2) + self.assertEqual(len(bus.published_messages), 2) + + def test_can1_whitelist_publishes_rear_lock_candidate_with_debug_command(self) -> None: + bus = self.transport.bus(channel=1) + bus.set_debug_command("door rear lock") + sent = bus.send(0x32B, bytes.fromhex("0A 40 00 00 00 00 00 00")) + + self.assertEqual(sent.arbitration_id, 0x32B) + self.assertFalse(sent.is_fd) + topic, raw_payload, qos, retain = self.client.published[-1] + payload = json.loads(raw_payload) + self.assertEqual(topic, "n7/n7-headunit/can/command") + self.assertEqual(qos, 1) + self.assertFalse(retain) + self.assertEqual(payload["command"], "door rear lock") + self.assertEqual(payload["channel"], "can1") + self.assertEqual(payload["canId"], "0x32B") + self.assertEqual(payload["frameType"], "classic") + self.assertFalse(payload["brs"]) + self.assertEqual(payload["data"], "0A 40 00 00 00 00 00 00") + + def test_wireless_whitelisted_climate_plan_publishes_without_safety_or_feedback_frames( + self, + ) -> None: + service = SeatDemoService(REPOSITORY_ROOT) + service._vehicle_mqtt_transport = self.transport + service.vehicle_mqtt_settings = SimpleNamespace(feedback_timeout_ms=1) + actions = [ + { + "domain": "climate", + "capability": "climate.set_temperature", + "target": "driver", + "operation": "set", + "parameters": {"temperature_c": 24}, + }, + { + "domain": "climate", + "capability": "climate.set_fan_level", + "target": "vehicle", + "operation": "set", + "parameters": {"level": 2}, + }, + ] + runtime = NativeCanRuntime(REPOSITORY_ROOT) + config = runtime.build_function_config( + "climate", actions, "req-wireless-climate", "local_rules" + ) + plan = runtime.compile(config) + + with ( + patch( + "cognitive_agent.application.seat_service.observe_vehicle_safety", + side_effect=AssertionError( + "wireless whitelist publish must not require 0x342/0x4EB" + ), + ), + patch("cognitive_agent.application.native_can_runtime.time.sleep"), + ): + reports = service._execute_orchestrated_reports( + {"native_climate": config}, + {"native_climate": plan}, + {"native_climate"}, + {}, + hardware_request=True, + simulated_hardware_request=False, + simulation_fault="normal", + control_transport="wireless_mqtt", + ) + + report = reports["native_climate"] + self.assertEqual(report["spec"]["status"], "succeeded") + self.assertEqual( + report["spec"]["vehicle_safety_preflight"]["policy"], + "mqtt_whitelist_publish", + ) + self.assertEqual(len(self.client.published), 6) + payloads = [json.loads(item[1]) for item in self.client.published] + self.assertTrue( + all( + topic == "n7/n7-headunit/can/command" + for topic, _payload, _qos, _retain in self.client.published + ) + ) + self.assertTrue(all(payload["channel"] == "can1" for payload in payloads)) + self.assertTrue(all(payload["canId"] == "0x1DE" for payload in payloads)) + self.assertEqual( + [payload["command"] for payload in payloads], + ["ac temp driver 24"] * 3 + ["ac fan 2"] * 3, + ) + self.assertEqual( + [payload["data"] for payload in payloads], + [ + "00 00 00 00 18 00 00 00", + "00 00 00 00 18 00 00 00", + "00 00 00 00 18 00 00 00", + "20 00 00 00 00 00 00 00", + "20 00 00 00 00 00 00 00", + "20 00 00 00 00 00 00 00", + ], + ) + for result in report["spec"]["results"]: + self.assertEqual(result["status"], "succeeded") + self.assertEqual(result["telemetry"]["delivery_status"], "published") + self.assertFalse(result["telemetry"]["feedback_required"]) + self.assertFalse(result["telemetry"]["physical_effect_confirmed"]) + self.assertEqual(len(result["telemetry"]["mqtt_messages"]), 3) + self.assertTrue( + all( + message["topic"] == "n7/n7-headunit/can/command" + for message in result["telemetry"]["mqtt_messages"] + ) + ) + + def test_model_added_nonwhitelisted_action_does_not_block_whitelisted_publish( + self, + ) -> None: + service = SeatDemoService(REPOSITORY_ROOT) + service._vehicle_mqtt_transport = self.transport + service.vehicle_mqtt_settings = SimpleNamespace( + enabled=True, + configured=True, + feedback_timeout_ms=1, + authorize=lambda _token: None, + ) + model_output = { + "detected_domains": ["climate", "ambient_light"], + "requests": [ + { + "domain": "ambient_light", + "capability": "ambient_light.set_mode", + "target_scope": "vehicle", + "operation": "set", + "parameters": [{"name": "mode", "value": "cinema"}], + "reason": "模型根据观影状态补充了氛围灯模式。", + }, + { + "domain": "climate", + "capability": "climate.set_temperature", + "target_scope": "driver", + "operation": "set", + "parameters": [{"name": "temperature_c", "value": 24}], + "reason": "用户明确指定温度。", + }, + { + "domain": "climate", + "capability": "climate.set_fan_level", + "target_scope": "vehicle", + "operation": "set", + "parameters": [{"name": "level", "value": 2}], + "reason": "用户明确指定风量。", + }, + ], + "ambiguities": [], + "decision_summary": ["执行用户明确指定的观影空调设置。"], + "confidence": 0.96, + } + + with tempfile.TemporaryDirectory() as temporary_directory: + service.artifact_root = Path(temporary_directory) + service.openai.api_key = "test-key" + with ( + patch.object(service.openai, "interpret", return_value=model_output), + patch("cognitive_agent.application.native_can_runtime.time.sleep"), + ): + result = service.process( + "进入观影状态:空调24度、风量2档,", + provider="openai", + conversation_id="conv-wireless-movie-climate", + execution_mode="hardware", + control_transport="wireless_mqtt", + ) + + self.assertEqual(result["status"], "partially_succeeded") + self.assertEqual( + [item["capability"] for item in result["deferred_requests"]], + ["ambient_light.set_mode"], + ) + self.assertEqual( + result["deferred_requests"][0]["rejection_code"], + "WIRELESS_MQTT_CONTROL_NOT_WHITELISTED", + ) + self.assertEqual(len(self.client.published), 6) + self.assertTrue( + all( + topic == "n7/n7-headunit/can/command" + and json.loads(payload)["canId"] == "0x1DE" + for topic, payload, _qos, _retain in self.client.published + ) + ) def test_can0_bus_publishes_only_classic_passenger_seat_control(self) -> None: bus = self.transport.bus(channel=0) sent = bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00")) payload = json.loads(self.client.published[-1][1]) self.assertEqual(sent.channel, 0) - self.assertEqual(payload["channel"], 0) - self.assertFalse(payload["is_fd"]) + self.assertEqual(payload["channel"], "can0") + self.assertEqual(payload["canId"], "0x6A0") + self.assertEqual(payload["command"], "") + self.assertEqual(payload["frameType"], "classic") + self.assertFalse(payload["brs"]) with self.assertRaisesRegex(Exception, "approved CAN0 control set"): bus.send(0x431, bytes(8)) with self.assertRaisesRegex(Exception, "requires classic CAN"): @@ -524,7 +827,10 @@ class MqttCanTransportTests(unittest.TestCase): "n7/n7-headunit/can/state", { "signals": [ - {"channel": "can0", "canId": "0x302", "bytes": [0] * 8}, + {"channel": "can0", "canId": "0x6A0", "bytes": + [0xC7, 0x21, 0x60, 1, 0, 0, 0, 0]}, + {"channel": "can0", "canId": "0x6A2", "bytes": + [0x7C, 0x21, 0x60, 0, 0xE0, 0x01, 0xE0, 0x01]}, { "channel": "can1", "canId": "0x342", @@ -534,6 +840,7 @@ class MqttCanTransportTests(unittest.TestCase): ] }, ) + self.assertEqual(can0.receive(0).arbitration_id, 0x6A2) self.assertEqual(can0.receive(0).arbitration_id, 0x302) self.assertEqual(can1.receive(0, include_fd=True).arbitration_id, 0x342) self.assertIsNone(can0.receive(0)) @@ -546,10 +853,6 @@ class MqttCanTransportTests(unittest.TestCase): 2: (373 << 16) | 266, 3: (917 << 16) | 451, 4: (647 << 16) | 575, - 5: 0x11, - 6: 0x11, - 7: 0x11, - 8: 0x11, }.items(): sequence = 0x40 + selector signals.extend( diff --git a/tests/unit/test_mqtt_ota_seat_executor.py b/tests/unit/test_mqtt_ota_seat_executor.py index 7ad0aba..d03edfd 100644 --- a/tests/unit/test_mqtt_ota_seat_executor.py +++ b/tests/unit/test_mqtt_ota_seat_executor.py @@ -8,6 +8,7 @@ from cognitive_agent.application.mqtt_ota_seat_executor import ( MqttOtaSeatExecutor, ) from cognitive_agent.infrastructure.can.gateway import CanFrame +from seat_domain import parse_local_intent def _axis_frame(axis: int, value_x100: int, *, valid: bool = True) -> CanFrame: @@ -34,8 +35,12 @@ class _FakeOtaBus: def __init__(self, initial_frames: list[CanFrame]) -> None: self.frames = deque(initial_frames) self.sent: list[CanFrame] = [] + self.debug_commands: list[str] = [] self.closed = False + def set_debug_command(self, command: str) -> None: + self.debug_commands.append(command) + def send( self, arbitration_id: int, @@ -110,6 +115,14 @@ def _function_config(capability: str, parameters: dict[str, object]) -> dict[str class MqttOtaSeatExecutorTests(unittest.TestCase): + def test_seat_height_language_maps_to_internal_axis_one_capability(self) -> None: + parsed = parse_local_intent("副驾座椅高度调到60%") + + self.assertEqual(parsed["ambiguities"], []) + self.assertEqual(parsed["actions"][0]["axis"], "cushion_tilt") + self.assertEqual(parsed["actions"][0]["position_pct"], 60) + self.assertIn("目标:副驾座椅高度位置60%。", parsed["decision_summary"]) + def test_absolute_move_uses_runtime_move_and_fresh_position_confirmation(self) -> None: bus = _FakeOtaBus([_axis_frame(0, 4_000)]) executor = MqttOtaSeatExecutor( # type: ignore[arg-type] @@ -146,6 +159,10 @@ class MqttOtaSeatExecutorTests(unittest.TestCase): self.assertEqual(bus.sent[0].data[2], 0x64) self.assertEqual(bus.sent[0].data[3], 0) self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 500) + self.assertEqual( + bus.debug_commands, + ["seat passenger longitudinal position 50"], + ) self.assertEqual( report["spec"]["results"][0]["telemetry"]["effective_position_pct"], 50, @@ -213,6 +230,56 @@ class MqttOtaSeatExecutorTests(unittest.TestCase): self.assertIn("does not provide wireless thermal control", report["spec"]["results"][0]["message"]) self.assertEqual(bus.sent, []) + def test_dynamic_semantic_modes_use_seat_ota_firmware_selectors(self) -> None: + for semantic_mode, firmware_selector, mode_name in ( + (1, 2, "木马模式"), + (2, 1, "动态摇篮模式"), + ): + with self.subTest(semantic_mode=semantic_mode): + bus = _FakeOtaBus([]) + executor = MqttOtaSeatExecutor( # type: ignore[arg-type] + _StatusExecutor(), + lambda: bus, + ) + plan = { + "spec": { + "commands": [ + { + "operation_ref": "operation-1", + "message": "T2G_Seat_Move_Command_300", + "signals": {"Opcode": 0x0E, "Motor_Selector": 0}, + } + ] + } + } + + report = executor.execute( + plan, + _function_config( + "seat.start_dynamic_mode", + {"mode_id": semantic_mode}, + ), + ) + + self.assertEqual(report["spec"]["status"], "succeeded") + self.assertEqual(bus.sent[0].data[2], 0x66) + self.assertEqual(bus.sent[0].data[3], firmware_selector) + self.assertEqual( + bus.debug_commands, + [ + "seat passenger dynamic start " + + ("rocking-horse" if semantic_mode == 1 else "cradle") + ], + ) + self.assertEqual( + int.from_bytes(bus.sent[0].data[4:8], "little"), + 300, + ) + telemetry = report["spec"]["results"][0]["telemetry"] + self.assertEqual(telemetry["dynamic_mode_id"], semantic_mode) + self.assertEqual(telemetry["ota_dynamic_selector"], firmware_selector) + self.assertEqual(telemetry["dynamic_mode_name"], mode_name) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_native_can_feedback.py b/tests/unit/test_native_can_feedback.py index 5db1f7f..792219d 100644 --- a/tests/unit/test_native_can_feedback.py +++ b/tests/unit/test_native_can_feedback.py @@ -35,6 +35,13 @@ class NativeCanFeedbackTests(unittest.TestCase): self.assertTrue(feedback_matches("climate_temperature:driver:24", feedback)) self.assertFalse(feedback_matches("climate_temperature:driver:25", feedback)) + def test_climate_feedback_decodes_front_defrost(self) -> None: + decoded = decode_feedback( + self.frame(0x404, "43A9116c1818a2dd") + )["domain_state"]["climate"] + + self.assertEqual(decoded["front_defrost"], "on") + def test_window_position_match_uses_authoritative_0x388(self) -> None: feedback = self.frame(0x388, "0000320000005500") self.assertTrue(feedback_matches("window_position:front_left:50", feedback)) @@ -90,6 +97,27 @@ class NativeCanFeedbackTests(unittest.TestCase): self.assertTrue(feedback_matches("driver_seat_thermal:heating:low", feedback)) self.assertFalse(feedback_matches("driver_seat_thermal:ventilation:high", feedback)) + def test_driver_seat_feedback_supports_all_three_thermal_levels(self) -> None: + driver = decode_feedback( + self.frame(0x4FA, "1C00000000010000") + )["actuator_state"]["driver"] + + self.assertEqual(driver["ventilation"], {"state": "on", "level": 3}) + self.assertEqual(driver["heating"], {"state": "on", "level": 2}) + + def test_38a_matrix_decodes_front_lighting_feedback(self) -> None: + lights = decode_feedback( + self.frame(0x38A, "1969000000000000") + )["domain_state"]["front_light"] + + self.assertEqual(lights["position_light"]["state"], "on") + self.assertEqual(lights["low_beam"]["state"], "off") + self.assertEqual(lights["high_beam"]["state"], "on") + self.assertEqual(lights["fog_light"]["state"], "on") + self.assertEqual(lights["brake_light"]["state"], "flashing") + self.assertEqual(lights["reverse_light"]["state"], "off") + self.assertEqual(lights["all_weather_light"]["state"], "on") + def test_driver_position_feedback_never_claims_absolute_position(self) -> None: decoded = decode_feedback( self.frame(0x494, "151515058c28203c") diff --git a/tests/unit/test_native_state_sync.py b/tests/unit/test_native_state_sync.py index 2548553..0d77699 100644 --- a/tests/unit/test_native_state_sync.py +++ b/tests/unit/test_native_state_sync.py @@ -157,7 +157,7 @@ class NativeStateSyncTests(unittest.TestCase): self.assertEqual(climate["cycle_mode"], "internal") self.assertEqual(climate["feedback_source"], "physical_can_ch1") self.assertEqual(state["domain_devices"]["roof"]["shade_position_pct"], 2) - self.assertEqual(state["domain_devices"]["ambient_light"]["brightness_pct"], 100) + self.assertEqual(state["domain_devices"]["ambient_light"]["brightness_pct"], 60) self.assertEqual( state["domain_devices"]["door"]["locks"]["rear_left"]["feedback_source"], "physical_can_ch1", @@ -190,6 +190,58 @@ class NativeStateSyncTests(unittest.TestCase): ) self.assertEqual(after, before) + def test_verified_wireless_ch1_feedback_updates_realtime_state(self) -> None: + state = self.store.synchronize_physical_ch1_feedback( + "conv-native-sync", + [{ + "source": "wireless_mqtt_ch1", + "channel": 1, + "frame_id": "0x41A", + "feedback_frame_id": "0x41A", + "valid": True, + "verification": { + "authoritative": True, + "channel": 1, + "simulated": False, + "read_only": True, + "transport": "wireless_mqtt", + "frame_id": "0x41A", + "payload_size": 8, + }, + "domain_state": { + "roof": {"shade_position_pct": 92, "motion_state": "stopped"} + }, + }], + ) + + roof = state["domain_devices"]["roof"] + self.assertEqual(roof["shade_position_pct"], 92) + self.assertEqual(roof["feedback_source"], "wireless_mqtt_ch1") + self.assertIn("wireless_mqtt_ch1_state_updated_at", state) + + def test_wireless_source_without_mqtt_transport_verification_is_ignored(self) -> None: + before = self.store.snapshot("conv-native-sync") + after = self.store.synchronize_physical_ch1_feedback( + "conv-native-sync", + [{ + "source": "wireless_mqtt_ch1", + "channel": 1, + "frame_id": "0x41A", + "feedback_frame_id": "0x41A", + "valid": True, + "verification": { + "authoritative": True, + "channel": 1, + "simulated": False, + "read_only": True, + "frame_id": "0x41A", + "payload_size": 8, + }, + "domain_state": {"roof": {"shade_position_pct": 92}}, + }], + ) + self.assertEqual(after, before) + def test_verified_read_only_ch1_feedback_updates_only_driver(self) -> None: state = self.store.synchronize_physical_ch1_feedback( "conv-native-sync", diff --git a/tests/unit/test_requested_frontend_enhancements.py b/tests/unit/test_requested_frontend_enhancements.py index b65228b..0973fa3 100644 --- a/tests/unit/test_requested_frontend_enhancements.py +++ b/tests/unit/test_requested_frontend_enhancements.py @@ -1,5 +1,8 @@ from __future__ import annotations +import json +import shutil +import subprocess from pathlib import Path @@ -35,6 +38,37 @@ def test_frontend_only_exposes_user_orchestration_and_extension() -> None: assert "v2RoleSelect" not in console +def test_retired_layout_is_removed_instead_of_permanently_hidden() -> None: + html = source("index.html") + app = source("app.js") + console = source("console-v2.js") + styles = source("console-v2.css") + + for retired_markup in ( + 'class="topbar"', + 'class="example-group"', + 'id="safetyStrip"', + 'class="pipeline"', + 'id="gestureBackdrop"', + ): + assert retired_markup not in html + + for retired_runtime_hook in ( + "v2-legacy-parking", + "v2-legacy-hidden", + "#connectionBadge", + "#modelBadge", + "#hardwareBadge", + "#executionBadge", + "#logoutButton", + "#gestureToggleButton", + "#pwaBadge", + ): + assert retired_runtime_hook not in app + assert retired_runtime_hook not in console + assert retired_runtime_hook not in styles + + def test_extension_pet_and_transient_vehicle_state_restart_contract() -> None: extension = source("self-extension.js") styles = source("self-extension.css") @@ -138,6 +172,278 @@ def test_vehicle_device_status_cards_prioritize_seats_and_expose_manual_controls assert ".v2-device-control-apply" in styles +def test_passenger_can0_status_ui_uses_only_seat_ota_four_axis_contract() -> None: + app = source("app.js") + console = source("console-v2.js") + + assert 'new Set(["longitudinal", "height", "backrest_recline", "legrest_height"])' in app + assert 'if (thermalRow) thermalRow.hidden = seat === "passenger";' in app + assert 'height: seatHeight,' in app + assert '"seat.set_cushion_tilt": "调节座椅高度"' in app + assert 'rows: () => [["前后", seatAxisValue("passenger", "longitudinal")], ["高度", seatAxisValue("passenger", "cushion_tilt")]' in console + assert 'rangeControl("cushion_tilt", "高度"' in console + assert 'rangeControl("legrest_extension"' not in console + + +def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None: + console = source("console-v2.js") + styles = source("console-v2.css") + + assert 'make("details", "v2-can-command-panel"' in console + assert 'canCommandPanel.id = "v2CanCommandPanel"' in console + assert 'id="v2CanCommandCount">0' in console + assert 'id="v2CanCommandList"' in console + assert "canCommandPanel.open" not in console + assert "function parsedCanCommandForOperation(operation = {})" in console + assert "function parsedCanCommands(result = {})" in console + assert "getParsedCanCommands: (result) => parsedCanCommands(result)" in console + assert "const configurations = result.function_configs || {};" in console + assert "const plans = result.command_plans || {};" in console + assert 'const command = (category, text, channel = "CAN1", frame = "")' in console + assert 'data-can-channel="${escapeHtml(item.channel)}"' in console + assert 'class="v2-can-command-channel"' in console + assert 'window.addEventListener("cognitive-agent:result"' in console + assert '$("#requestForm")?.addEventListener("submit"' in console + + for command_contract in ( + "door rear ${action}", + "door vehicle ${lockState}", + "window ${target} ${motion}", + "window ${target} position ${position}", + "ac ${onOff}", + "ac fan ${level}", + "ac mode ${mode}", + "ac temp ${target} ${temperature}", + "ac control ${mode}", + "ac cycle ${mode}", + "outlet ${target} percent ${horizontal} ${vertical}", + "outlet ${target} position ${horizontal} ${vertical}", + "seat driver ${feature} ${setting}", + "seat driver massage strength ${strength}", + "seat driver ${axis} ${direction} ${duration}", + "seat driver memory recall ${position}", + "seat driver position ${position}", + "light indoor ${onOff}", + "light reading all ${onOff}", + "light ambient color ${color}", + "light ambient brightness ${level}", + "shade position ${position}", + "seat passenger ${axis[1]} position ${position}", + "seat passenger dynamic start ${dynamic.name}", + "seat passenger dynamic stop", + "otaCan0Frame(0x64, axis[2], targetPermille)", + "otaCan0Frame(0x66, dynamic.selector, 300)", + "otaCan0Frame(0x63, 0xFF, 0)", + ): + assert command_contract in console + + assert ".v2-can-command-panel[open]" in styles + assert ".v2-can-command-list li code" in styles + assert ".v2-can-command-channel" in styles + assert 'li[data-can-channel="CAN0"]' in styles + assert ".v2-sidebar-collapsed .v2-can-command-panel" in styles + + +def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands() -> None: + node = shutil.which("node") + if node is None: + return + console = source("console-v2.js") + start = console.index(" const normalizedCanTarget") + end = console.index("\n\n function renderParsedCanCommands", start) + helpers = console[start:end] + sample = { + "function_configs": { + "seat": { + "spec": { + "operations": [ + { + "capability": "seat.set_backrest_recline", + "target": {"zone": "front_right"}, + "parameters": {"position_pct": 36, "angle_deg": 120}, + }, + ] + } + }, + "native_climate": { + "spec": { + "operations": [ + { + "capability": "climate.set_temperature", + "target": {"zone": "driver"}, + "parameters": {"temperature_c": 24}, + } + ] + } + }, + }, + "command_plans": { + "seat": {"spec": {"commands": [{}]}}, + "native_climate": {"spec": {"commands": [{}]}}, + }, + } + script = ( + f"{helpers}\n" + f"const sample = {json.dumps(sample)};\n" + "process.stdout.write(JSON.stringify(parsedCanCommands(sample)));" + ) + completed = subprocess.run( + [node, "-e", script], + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(completed.stdout) == [ + { + "channel": "CAN0", + "category": "副驾座椅靠背调节", + "command": "seat passenger backrest position 36", + "frame": "0x6A0 · C7 64 02 68 01 00 00", + }, + { + "channel": "CAN1", + "category": "空调温度", + "command": "ac temp driver 24", + }, + ] + + +def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() -> None: + app = source("app.js") + styles = source("styles.css") + html = source("index.html") + + assert 'class="mode-editor-flow-preview empty"' in html + assert "function modeEditorReadableCommands(flow)" in app + assert "function modeEditorMessagesForOperation(flow, readable, operationRef, plan)" in app + assert 'command?.signals?.special_handler === "driver_seat_memory_recall"' in app + assert "Array.isArray(special?.frame_sequence)" in app + assert "function modeEditorAppendCommandCard(preview, item, flow)" in app + assert 'card.className = "mode-editor-command-card"' in app + assert 'title.textContent = flow?.control_transport === "wireless_mqtt" ? "MQTT 消息序列"' in app + assert 'source: "published"' in app + assert 'source: flow?.control_transport === "wireless_mqtt" ? "projected" : "can"' in app + assert "function_configs: functionConfigs" in app + assert "execution_steps: executionSteps" in app + + assert ".mode-editor-command-card" in styles + assert ".mode-editor-command-meta" in styles + assert ".mode-editor-message-sequence" in styles + assert '.mode-editor-command-card[data-can-channel="CAN0"]' in styles + + +def test_mode_editor_prioritizes_can_flow_display_area() -> None: + app = source("app.js") + html = source("index.html") + styles = source("styles.css") + + assert 'class="mode-editor-setup"' in html + assert 'class="mode-editor-inputs"' in html + assert "grid-template-rows: minmax(0, 30%) minmax(0, 1fr) auto auto;" in styles + assert "grid-template-columns: minmax(150px, 0.32fr) minmax(0, 1fr);" in styles + assert ".mode-editor-instruction-field textarea" in styles + assert "resize: none;" in styles + assert ".mode-editor-flow-preview" in styles + assert "overflow: auto;" in styles + assert 'id="modeEditorFlowExpand"' in html + assert ".mode-editor.flow-expanded" in styles + assert ".mode-editor-backdrop.flow-expanded" in styles + assert "width: 100vw;" in styles + assert "height: 100dvh;" in styles + assert "grid-template-rows: minmax(0, 1fr);" in styles + assert "touch-action: pan-y;" in styles + assert "scrollbar-gutter: stable;" in styles + assert "max-height: 220px;" not in styles + assert "function setModeEditorFlowExpanded(expanded)" in app + assert '$("#modeEditorFlowExpand").addEventListener("click", toggleModeEditorFlowExpanded);' in app + + +def test_mode_editor_prepares_one_to_many_mqtt_message_sequences() -> None: + node = shutil.which("node") + if node is None: + return + app = source("app.js") + console = source("console-v2.js") + console_start = console.index(" const normalizedCanTarget") + console_end = console.index("\n\n function renderParsedCanCommands", console_start) + console_helpers = console[console_start:console_end] + app_start = app.index("function modeEditorCommandCategory") + app_end = app.index("\n\nfunction modeEditorAppendCommandCard", app_start) + app_helpers = app[app_start:app_end] + sample = { + "control_transport": "wireless_mqtt", + "function_configs": { + "native_climate": { + "spec": { + "operations": [ + { + "id": "temperature", + "capability": "climate.set_temperature", + "target": {"zone": "driver"}, + "parameters": {"temperature_c": 25}, + } + ] + } + } + }, + "plans": { + "native_climate": { + "spec": { + "commands": [ + { + "operation_ref": "temperature", + "signals": { + "frame_id": "0x1DE", + "data_hex": "00 00 00 00 19 00 00 00", + "is_fd": False, + "bitrate_switch": False, + "debug_command": "ac temp driver 25", + }, + "send_offset_ms": 20, + "repeat": {"count": 3, "period_ms": 100}, + } + ] + } + } + }, + "execution_steps": [], + } + script = ( + f"{console_helpers}\n" + "const state = {runtime:{vehicle_mqtt:{control_topic:" + '"n7/n7-headunit/can/command",qos:1,feedback_timeout_ms:1500}}};\n' + "const window = {CognitiveAgentConsoleV2:{getParsedCanCommands:" + "(result)=>parsedCanCommands(result)}};\n" + f"{app_helpers}\n" + f"const flow = {json.dumps(sample)};\n" + "process.stdout.write(JSON.stringify(modeEditorReadableCommands(flow)));" + ) + completed = subprocess.run( + [node, "-e", script], + check=True, + capture_output=True, + text=True, + ) + commands = json.loads(completed.stdout) + + assert len(commands) == 1 + assert commands[0]["channel"] == "CAN1" + assert commands[0]["category"] == "空调温度" + assert commands[0]["command"] == "ac temp driver 25" + assert [message["offset_ms"] for message in commands[0]["messages"]] == [ + 20, + 120, + 220, + ] + assert all( + message["topic"] == "n7/n7-headunit/can/command" + and message["payload"]["canId"] == "0x1DE" + and message["payload"]["command"] == "ac temp driver 25" + for message in commands[0]["messages"] + ) + + def test_interactive_console_keeps_actions_inside_composer_and_collapsed_rails_aligned() -> None: console = source("console-v2.js") styles = source("console-v2.css") @@ -162,15 +468,22 @@ def test_left_sync_rail_can_collapse_and_log_aligns_with_lower_workspace() -> No assert 'make("section", "v2-sync-sidebar"' in console assert '
' in console - assert '$(".v2-sync-sidebar-overview", syncSidebar)?.append(sidebarFooter);' in console + assert "syncOverview?.append(sidebarFooter);" in console assert '$(".v2-sync-sidebar-heading", syncSidebar)?.append(sidebarToggle);' in console assert "function syncSidebarLogBaseline()" in console + assert "const requiredOverviewHeight = overviewRect" in console + assert "overviewBottom - overviewRect.top + overviewPaddingBottom + 10" in console assert 'sidebar.style.setProperty("--v2-sync-log-top"' in console assert 'window.addEventListener("resize", syncSidebarLogBaseline);' in console assert "requestAnimationFrame(syncSidebarLogBaseline);" in console assert "--v2-sync-log-top: 291px;" in styles assert "grid-template-rows: calc(var(--v2-sync-log-top) - 10px) minmax(0, 1fr);" in styles + assert "grid-auto-rows: max-content;" in styles + assert "align-content: start;" in styles + assert "min-height: max-content;" in styles + assert "align-self: start;" in styles + assert "overflow: visible;" in styles assert ".v2-sync-sidebar > .v2-sync-log" in styles assert "grid-template-columns: 64px minmax(0, 1fr);" in styles assert ".v2-sidebar-collapsed #v2SidebarToggle" in styles diff --git a/tests/unit/test_self_extension.py b/tests/unit/test_self_extension.py index 797d274..9637e99 100644 --- a/tests/unit/test_self_extension.py +++ b/tests/unit/test_self_extension.py @@ -199,7 +199,14 @@ class SelfExtensionTests(unittest.TestCase): response.__enter__.return_value = response response.read.return_value = stream_body.encode("utf-8") - with patch.dict(os.environ, {"OPENAI_STREAM": "true"}, clear=False): + with patch.dict( + os.environ, + { + "OPENAI_STREAM": "true", + "IR_OPENAI_MODEL": "gpt-5.6-sol", + }, + clear=False, + ): service = SelfExtensionService(SimulationGateway()) with patch( "cognitive_agent.application.self_extension.urlopen", diff --git a/tests/unit/test_verified_body_can.py b/tests/unit/test_verified_body_can.py index eb6a0f4..f25a9a2 100644 --- a/tests/unit/test_verified_body_can.py +++ b/tests/unit/test_verified_body_can.py @@ -33,8 +33,12 @@ class _FakeBus: self.send_attempts: list[tuple[int, bytes, bool, bool]] = [] self.sent: list[CanFrame] = [] self.events: list[str] = [] + self.debug_commands: list[str] = [] self.closed = False + def set_debug_command(self, command: str) -> None: + self.debug_commands.append(command) + def send( self, arbitration_id: int, @@ -326,7 +330,7 @@ class VerifiedBodyCanTests(unittest.TestCase): self.assertEqual(vehicle[0]["capability"], "door.set_vehicle_lock") self.assertEqual(vehicle[0]["target"], "vehicle") - def test_legacy_rear_lock_is_retired_before_compile(self) -> None: + def test_rear_lock_compiles_only_the_matrix_backed_second_row_request(self) -> None: action = { "domain": "door", "capability": "door.set_rear_lock", @@ -335,23 +339,55 @@ class VerifiedBodyCanTests(unittest.TestCase): "parameters": {"state": "lock"}, } catalog_entry = self.runtime.capabilities[action["capability"]] - self.assertEqual(catalog_entry["execution_support"], "observational_only") - self.assertFalse(catalog_entry["physical_control_supported"]) - self.assertNotIn(action["capability"], self.runtime.supported_capabilities) - self.assertFalse(self.runtime.supports(action)) - with self.assertRaisesRegex( - NativeCanCompileError, - r"unsupported.*current CH1.*0x32B.*observational only", - ): - self.runtime.build_function_config( - "door", [action], "retired-rear-lock", "local_rules" - ) - with self.assertRaisesRegex( - NativeCanCompileError, r"unsupported.*current CH1.*observational only" - ): - self.runtime._frame_specs({"capability": action["capability"]}) + self.assertEqual( + catalog_entry["execution_support"], "matrix_candidate_with_feedback" + ) + self.assertTrue(catalog_entry["physical_control_supported"]) + self.assertIn(action["capability"], self.runtime.supported_capabilities) + self.assertTrue(self.runtime.supports(action)) + cases = ( + ("lock", "0A 40 00 00 00 00 00 00"), + ("unlock", "0A 80 00 00 00 00 00 00"), + ) + for state, payload in cases: + with self.subTest(state=state): + command = self.compile_action( + "door", "door.set_rear_lock", "rear", {"state": state} + ) + self.assertEqual(command["signals"]["frame_id"], "0x32B") + self.assertEqual(command["signals"]["data_hex"], payload) + self.assertFalse(command["signals"]["is_fd"]) + self.assertFalse(command["signals"]["bitrate_switch"]) + self.assertNotIn("repeat", command) + self.assertEqual(command["signals"]["feedback_ids"], "0x407") + self.assertEqual( + command["signals"]["expected_feedback"], f"rear_lock:{state}" + ) + exact = _parse_exact_command(f"door rear {state}") + self.assertEqual(exact[0]["capability"], "door.set_rear_lock") + self.assertEqual(exact[0]["target"], "rear") - self.assertEqual(_parse_exact_command("door rear lock"), []) + def test_climate_cycle_uses_verified_1de_bits_and_404_feedback(self) -> None: + cases = ( + ("internal", "00 00 08 00 00 00 00 00"), + ("external", "00 00 10 00 00 00 00 00"), + ) + for mode, payload in cases: + with self.subTest(mode=mode): + command = self.compile_action( + "climate", + "climate.set_cycle_mode", + "vehicle", + {"mode": mode}, + ) + self.assertEqual(command["signals"]["frame_id"], "0x1DE") + self.assertEqual(command["signals"]["data_hex"], payload) + self.assertEqual(command["repeat"], {"count": 3, "period_ms": 100}) + self.assertEqual(command["signals"]["feedback_ids"], "0x404") + self.assertEqual( + command["signals"]["expected_feedback"], + f"climate_cycle:{mode}", + ) def test_ambient_controls_use_only_verified_3fc_layouts(self) -> None: cases = ( @@ -426,6 +462,10 @@ class VerifiedBodyCanTests(unittest.TestCase): self.assertFalse(command["signals"]["is_fd"]) self.assertEqual(command["signals"]["feedback_ids"], "0x41A") self.assertEqual(command["signals"]["special_handler"], "shade_preset") + self.assertEqual( + command["signals"]["debug_command"], + f"shade position {position}", + ) arbitrary = self.compile_action( "roof", "roof.set_shade_position", "vehicle", {"position_pct": 37} @@ -433,10 +473,35 @@ class VerifiedBodyCanTests(unittest.TestCase): self.assertEqual(arbitrary["signals"]["frame_id"], "0x431") self.assertNotEqual(arbitrary["signals"]["frame_id"], "0x4F5") self.assertEqual(arbitrary["signals"]["special_handler"], "shade_position") + self.assertEqual(arbitrary["signals"]["debug_command"], "shade position 37") special = arbitrary["signals"]["special_parameters"] self.assertIn('"fresh_feedback_timeout_ms": 2500', special) self.assertIn('"stop_data_hex": "40 00 00 00 00 00 00 00"', special) + def test_executor_attaches_compiled_debug_command_to_mqtt_style_bus(self) -> None: + config = self.runtime.build_function_config( + "roof", + [ + { + "domain": "roof", + "capability": "roof.set_shade_position", + "target": "vehicle", + "operation": "set", + "parameters": {"position_pct": 100}, + } + ], + "shade-debug-command", + "local_rules", + ) + plan = self.runtime.compile(config) + bus = _FakeBus([self.shade_feedback(100, motion=1)]) + executor = NativeCanExecutor(lambda: bus, feedback_timeout_ms=1) + + report = executor.execute(self.runtime, plan, config) + + self.assertEqual(report["spec"]["status"], "succeeded") + self.assertEqual(bus.debug_commands, ["shade position 100"]) + def test_shade_preset_executor_waits_for_authoritative_target(self) -> None: command = self.compile_action( "roof", "roof.set_shade_position", "vehicle", {"position_pct": 100}