From a952f37cebcb0cab17114e2a6f720b4f57acd80b Mon Sep 17 00:00:00 2001 From: vee Date: Mon, 3 Aug 2026 10:20:55 +0800 Subject: [PATCH] feat: add local hardware approval leases --- apps/web-console/app.js | 137 ++++++++- apps/web-console/hardware-control.css | 39 +++ apps/web-console/hardware-control.html | 46 +++ apps/web-console/hardware-control.js | 104 +++++++ apps/web-console/index.html | 12 +- apps/web-console/service-worker.js | 4 +- apps/web-console/styles.css | 48 ++++ docs/workflows/t2g-seat-hardware-bench.md | 14 + .../application/seat_service.py | 49 +++- .../infrastructure/can/__init__.py | 8 + .../infrastructure/can/access.py | 268 ++++++++++++++++++ src/cognitive_agent/server.py | 151 +++++++++- tests/e2e/test_seat_web_api.py | 124 +++++++- tests/unit/test_hardware_access_controller.py | 110 +++++++ 14 files changed, 1089 insertions(+), 25 deletions(-) create mode 100644 apps/web-console/hardware-control.css create mode 100644 apps/web-console/hardware-control.html create mode 100644 apps/web-console/hardware-control.js create mode 100644 src/cognitive_agent/infrastructure/can/access.py create mode 100644 tests/unit/test_hardware_access_controller.py diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 6df02e3..5d62da1 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -10,6 +10,20 @@ function loadConversationId() { return conversationId; } +function loadHardwareClientId() { + const key = "cognitive-agent.hardware-client-id"; + const stored = localStorage.getItem(key); + if (/^hwc-[a-z0-9-]{8,80}$/.test(stored || "")) return stored; + const clientId = `hwc-${crypto.randomUUID()}`; + localStorage.setItem(key, clientId); + return clientId; +} + +function defaultHardwareClientLabel(clientId) { + return localStorage.getItem("cognitive-agent.hardware-client-label") + || `测试终端-${clientId.slice(-6)}`; +} + const state = { provider: "openai", executionMode: "dry_run", @@ -23,6 +37,9 @@ const state = { seatFeedbackSource: "simulated", availableSeatAxes: null, connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting", + hardwareClientId: loadHardwareClientId(), + hardwareAccess: null, + hardwareAccessPoller: null, }; const $ = (selector, root = document) => root.querySelector(selector); @@ -2509,6 +2526,89 @@ function showToast(message) { showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200); } +function isLocalDecisionHost() { + return ["127.0.0.1", "localhost", "::1"].includes(window.location.hostname); +} + +function renderHardwareAccess(status) { + state.hardwareAccess = status; + const statusNode = $("#hardwareAccessStatus"); + const requestButton = $("#requestHardwareAccess"); + const releaseButton = $("#releaseHardwareAccess"); + if (!statusNode || !requestButton || !releaseButton) return; + const accessState = status?.state || "none"; + statusNode.dataset.state = accessState; + if (accessState === "approved") { + statusNode.textContent = `本地已同意。剩余空闲时间 ${status.idle_remaining_seconds ?? 180} 秒;每次真实执行后重新计时。`; + requestButton.hidden = true; + releaseButton.hidden = false; + } else if (accessState === "pending") { + statusNode.textContent = `正在等待本地审批,当前排队第 ${status.queue_position || 1} 位;未获批前不会进入 CAN 链路。`; + requestButton.textContent = "更新申请"; + requestButton.hidden = false; + releaseButton.hidden = false; + } else if (accessState === "expired") { + statusNode.textContent = "因 3 分钟未执行测试,使用权已自动停止;再次使用请重新申请。"; + requestButton.textContent = "重新申请"; + requestButton.hidden = false; + releaseButton.hidden = true; + } else { + statusNode.textContent = "尚未申请;真实台架指令不会进入 CAN 链路。"; + requestButton.textContent = "向本地申请"; + requestButton.hidden = false; + releaseButton.hidden = true; + } +} + +async function loadHardwareAccessStatus({ silent = false } = {}) { + try { + const query = new URLSearchParams({ client_id: state.hardwareClientId }); + const response = await apiRequest(`/api/hardware/access/status?${query}`, { cache: "no-store" }); + if (response.status === 401) return; + const payload = await response.json(); + if (!response.ok) throw new Error(payload.message || payload.error); + renderHardwareAccess(payload); + } catch (error) { + if (!silent) showToast(`使用权状态读取失败:${error.message}`); + } +} + +async function requestHardwareAccess() { + const input = $("#hardwareClientLabel"); + const label = input.value.trim() || defaultHardwareClientLabel(state.hardwareClientId); + input.value = label; + localStorage.setItem("cognitive-agent.hardware-client-label", label); + try { + const response = await apiRequest("/api/hardware/access/request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: state.hardwareClientId, label }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.message || payload.error); + renderHardwareAccess(payload); + showToast(payload.state === "approved" ? "当前使用权仍然有效" : "申请已发送到台架本机,请等待审批"); + } catch (error) { + showToast(`申请失败:${error.message}`); + } +} + +async function releaseHardwareAccess() { + try { + const response = await apiRequest("/api/hardware/access/release", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: state.hardwareClientId }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.message || payload.error); + renderHardwareAccess(payload); + showToast("已停止使用真实台架;再次使用需要重新申请"); + } catch (error) { + showToast(`停止使用失败:${error.message}`); + } +} + function selectProvider(provider) { state.provider = provider; $$(".segment[data-provider]").forEach((button) => { @@ -2551,6 +2651,7 @@ function selectExecutionMode(mode) { const simulatedHardware = mode === "simulated_hardware"; const approvalRequired = Boolean(state.runtime?.hardware?.approval_required); $("#approvalField").hidden = !hardware || !approvalRequired; + $("#hardwareAccessField").hidden = !hardware; $("#simulationFaultField").hidden = !simulatedHardware; $("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim(); $("#executionBadge").innerHTML = hardware @@ -2791,10 +2892,13 @@ async function loadRuntime() { const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用"; const approvalText = hardware.approval_required ? "需授权令牌" : "调试免令牌"; const concurrencyText = hardware.concurrency_policy === "single_flight_fail_fast" - ? " · 单请求独占" + ? " · 动作防冲突" + : ""; + const accessText = hardware.access_policy === "local_approval_idle_lease" + ? ` · 本地审批 · 空闲 ${hardware.access_inactivity_timeout_seconds || 180} 秒自动停止` : ""; $("#hardwareNote").textContent = hardware.configured - ? `${hardware.backend} · ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · ${approvalText}${concurrencyText}` + ? `${hardware.backend} · ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · ${approvalText}${accessText}${concurrencyText}` : `真实执行已锁定:${profileText};请完成服务端启用、适配器、固件镜像${hardware.approval_required ? "和令牌" : ""}配置`; $("#hardwareAliasNote").textContent = "副驾座椅 → t2g_primary_seat(统一电机、加热和通风执行器)"; $("#autoFlashNote").textContent = hardware.auto_flash_enabled @@ -2805,7 +2909,7 @@ async function loadRuntime() { ? '硬件已配置' : '硬件锁定'; $("#hardwareCoverage").textContent = hardware.configured - ? `可选 · ${approvalText}${concurrencyText}` + ? `可选 · ${approvalText}${accessText}${concurrencyText}` : "已阻断"; $("#hardwareCoverage").className = hardware.configured ? "available" : "blocked"; const modeAvailable = state.executionMode === "hardware" @@ -2828,6 +2932,7 @@ async function synchronizePhysicalSeatState() { const query = new URLSearchParams({ conversation_id: state.conversationId, vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "", + hardware_client_id: state.hardwareClientId, }); try { const response = await apiRequest(`/api/hardware/seat-state?${query}`, { @@ -3647,6 +3752,14 @@ async function submitRequest(event) { showToast("请输入车舱控制请求"); return; } + if (state.executionMode === "hardware" + && !isLocalDecisionHost() + && state.hardwareAccess?.state !== "approved") { + showToast(state.hardwareAccess?.state === "pending" + ? "申请仍在等待本地审批" + : "请先申请并获得本地真实台架使用权"); + return; + } const button = $("#runButton"); button.disabled = true; button.classList.add("loading"); @@ -3674,6 +3787,9 @@ async function submitRequest(event) { conversation_id: state.conversationId, vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id, execution_mode: state.executionMode, + ...(state.executionMode === "hardware" + ? { hardware_client_id: state.hardwareClientId } + : {}), ...(state.executionMode === "simulated_hardware" ? { simulation_fault: $("#simulationFault").value } : {}), @@ -3711,7 +3827,12 @@ async function submitRequest(event) { } catch (error) { window.clearInterval(state.progressTimer); resetPipeline(); - if (error.code === "hardware_busy") { + if (error.code === "hardware_access_required") { + setRunStatus("failed", "ACCESS REQUIRED"); + $("#resultTitle").textContent = "真实台架使用权无效"; + await loadHardwareAccessStatus({ silent: true }); + showToast(error.message); + } else if (error.code === "hardware_busy") { setRunStatus("failed", "BENCH BUSY"); $("#resultTitle").textContent = "真实台架正在使用"; const retryHint = error.retryAfter > 0 ? ` 建议 ${error.retryAfter} 秒后重试。` : ""; @@ -3833,6 +3954,9 @@ document.addEventListener("DOMContentLoaded", async () => { refreshIcons(); initSeatTwin(); $("#requestForm").addEventListener("submit", submitRequest); + $("#hardwareClientLabel").value = defaultHardwareClientLabel(state.hardwareClientId); + $("#requestHardwareAccess").addEventListener("click", requestHardwareAccess); + $("#releaseHardwareAccess").addEventListener("click", releaseHardwareAccess); $("#utterance").addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); @@ -3867,6 +3991,11 @@ document.addEventListener("DOMContentLoaded", async () => { $$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download))); await loadAuthStatus(); await loadRuntime(); + await loadHardwareAccessStatus({ silent: true }); + state.hardwareAccessPoller = window.setInterval( + () => loadHardwareAccessStatus({ silent: true }), + 3000, + ); await synchronizePhysicalSeatState(); if (new URLSearchParams(window.location.search).get("autorun") === "1") { $("#requestForm").requestSubmit(); diff --git a/apps/web-console/hardware-control.css b/apps/web-console/hardware-control.css new file mode 100644 index 0000000..a3b476a --- /dev/null +++ b/apps/web-console/hardware-control.css @@ -0,0 +1,39 @@ +:root { color-scheme: dark; font-family: Inter, "Microsoft YaHei", system-ui, sans-serif; background: #070b12; color: #edf4ff; } +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 18% 0%, #17294b 0, transparent 35%), #070b12; } +.shell { width: min(1040px, calc(100% - 40px)); margin: 0 auto; padding: 54px 0 80px; } +header, .section-title, .policy, .request, .lease-details { display: flex; align-items: center; justify-content: space-between; gap: 20px; } +header { margin-bottom: 34px; } +h1, h2, p { margin: 0; } +h1 { font-size: clamp(30px, 5vw, 48px); letter-spacing: -.04em; } +h2 { font-size: 22px; } +.eyebrow { color: #65a8ff; font-size: 12px; letter-spacing: .16em; font-weight: 800; margin-bottom: 8px; } +.lead, small { color: #8d9caf; } +.lead { margin-top: 12px; } +a, button { font: inherit; } +a { color: #cfe4ff; text-decoration: none; border: 1px solid #2b3a50; border-radius: 10px; padding: 10px 14px; } +.policy, .card { background: rgba(15, 23, 36, .88); border: 1px solid #26354a; border-radius: 18px; box-shadow: 0 20px 60px rgba(0,0,0,.22); } +.policy { padding: 18px 22px; justify-content: flex-start; margin-bottom: 18px; } +.policy div { display: grid; gap: 3px; flex: 1; } +.dot { width: 10px; height: 10px; border-radius: 50%; background: #41d390; box-shadow: 0 0 16px #41d390; } +#clock { color: #8d9caf; font-variant-numeric: tabular-nums; } +.card { padding: 24px; margin-top: 18px; } +.active-card { border-color: #315a86; } +.section-title { margin-bottom: 20px; } +.count { min-width: 30px; height: 30px; display: grid; place-items: center; border-radius: 999px; background: #1a2d48; color: #7fbbff; font-weight: 800; } +.empty { padding: 26px 4px; color: #74849a; text-align: center; } +.request-list { display: grid; gap: 12px; } +.request { padding: 16px; border-radius: 13px; border: 1px solid #26364d; background: #0b121e; } +.identity { display: grid; gap: 5px; min-width: 0; } +.identity strong { font-size: 17px; } +.identity code, .identity span { color: #8394aa; font-size: 12px; overflow-wrap: anywhere; } +.actions { display: flex; gap: 8px; } +button { color: #f4f8ff; background: #1577db; border: 0; border-radius: 9px; padding: 10px 14px; cursor: pointer; font-weight: 750; } +button:hover { filter: brightness(1.12); } +button:disabled { opacity: .45; cursor: wait; } +button.secondary { background: #243348; } +button.danger { background: #b83449; } +.lease-details { justify-content: flex-start; flex-wrap: wrap; padding: 18px; border-radius: 13px; background: #0b121e; border: 1px solid #284f78; } +.lease-details .timer { margin-left: auto; color: #4ce0a0; font-weight: 850; font-size: 22px; font-variant-numeric: tabular-nums; } +.message { margin-top: 18px; min-height: 24px; color: #7fbbff; } +@media (max-width: 680px) { .shell { width: min(100% - 24px, 1040px); padding-top: 26px; } header, .request, .lease-details { align-items: flex-start; flex-direction: column; } .actions { width: 100%; } .actions button { flex: 1; } .lease-details .timer { margin-left: 0; } } diff --git a/apps/web-console/hardware-control.html b/apps/web-console/hardware-control.html new file mode 100644 index 0000000..d584c52 --- /dev/null +++ b/apps/web-console/hardware-control.html @@ -0,0 +1,46 @@ + + + + + + 真实台架本地决策台 + + + +
+
+
+

LOCAL BENCH AUTHORITY

+

真实台架本地决策台

+

只有本机可以批准、拒绝或停止终端的真实台架使用权。

+
+ 返回控制台 +
+ +
+ +
单终端租约获批终端可连续测试;最后一次真实执行后空闲 180 秒自动停止。
+ 正在连接… +
+ +
+
+

ACTIVE LEASE

当前使用者

+ +
+
当前无人获批使用真实台架。
+
+ +
+
+

PENDING REQUESTS

等待审批

+ 0 +
+
暂时没有申请。
+
+ +

+
+ + + diff --git a/apps/web-console/hardware-control.js b/apps/web-console/hardware-control.js new file mode 100644 index 0000000..d586c94 --- /dev/null +++ b/apps/web-console/hardware-control.js @@ -0,0 +1,104 @@ +const activeLease = document.querySelector("#activeLease"); +const pendingRequests = document.querySelector("#pendingRequests"); +const pendingCount = document.querySelector("#pendingCount"); +const revokeButton = document.querySelector("#revokeButton"); +const message = document.querySelector("#message"); +let snapshot = null; + +function escapeHtml(value) { + const node = document.createElement("span"); + node.textContent = String(value ?? ""); + return node.innerHTML; +} + +function showMessage(value, error = false) { + message.textContent = value; + message.style.color = error ? "#ff8797" : "#7fbbff"; +} + +async function api(path, body) { + const response = await fetch(path, { + method: body ? "POST" : "GET", + credentials: "same-origin", + headers: body ? { "Content-Type": "application/json" } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + if (response.status === 401) { + location.replace(`/login?next=${encodeURIComponent(location.pathname)}`); + throw new Error("请先登录本机服务"); + } + const payload = await response.json(); + if (!response.ok) throw new Error(payload.message || payload.error); + return payload; +} + +function render(data) { + snapshot = data; + pendingCount.textContent = String(data.pending.length); + if (data.active) { + const lease = data.active; + activeLease.className = "lease-details"; + activeLease.innerHTML = ` +
${escapeHtml(lease.label)}${escapeHtml(lease.client_id)}来源 ${escapeHtml(lease.client_address)} · 最近执行 ${escapeHtml(lease.last_activity_at)}
+
${Number(lease.idle_remaining_seconds)}s
`; + revokeButton.hidden = false; + revokeButton.dataset.clientId = lease.client_id; + } else { + activeLease.className = "empty"; + activeLease.textContent = "当前无人获批使用真实台架。"; + revokeButton.hidden = true; + } + if (!data.pending.length) { + pendingRequests.className = "request-list empty"; + pendingRequests.textContent = "暂时没有申请。"; + return; + } + pendingRequests.className = "request-list"; + pendingRequests.innerHTML = data.pending.map((request) => ` +
+
${escapeHtml(request.label)}${escapeHtml(request.client_id)}来源 ${escapeHtml(request.client_address)} · ${escapeHtml(request.requested_at)}
+
+ + +
+
`).join(""); +} + +async function refresh() { + try { + render(await api("/api/local/hardware-access")); + document.querySelector("#clock").textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false }); + } catch (error) { + showMessage(error.message, true); + } +} + +pendingRequests.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-action]"); + if (!button) return; + button.disabled = true; + try { + const action = button.dataset.action; + render(await api(`/api/local/hardware-access/${action}`, { client_id: button.dataset.clientId })); + showMessage(action === "approve" ? "已授予该终端真实台架使用权。" : "已拒绝该终端的申请。"); + } catch (error) { + showMessage(error.message, true); + } finally { + button.disabled = false; + } +}); + +revokeButton.addEventListener("click", async () => { + revokeButton.disabled = true; + try { + render(await api("/api/local/hardware-access/revoke", { client_id: revokeButton.dataset.clientId })); + showMessage("已停止该终端的真实台架使用权;再次使用必须重新申请。"); + } catch (error) { + showMessage(error.message, true); + } finally { + revokeButton.disabled = false; + } +}); + +refresh(); +setInterval(refresh, 2000); diff --git a/apps/web-console/index.html b/apps/web-console/index.html index c8c6978..894be1b 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -15,7 +15,7 @@ - + @@ -106,6 +106,16 @@

所有座椅能力仅控制副驾实体电机、加热与通风执行器

+ +