52 Commits

Author SHA1 Message Date
Miayui 0611d2a247 fix(firmware): retry atomic state writes on Windows file locks 2026-10-16 16:35:18 +08:00
Miayui f4ab5f4867 fix(web): unify mode previews and label window positions 2026-09-22 14:17:50 +08:00
Miayui 2b365b3922 fix(modes): only activate peripherals and show individual seat actions 2026-09-22 13:34:29 +08:00
Miayui 6b9ee0f349 fix(scenes): restore cinema seat controls and ordered romantic lighting 2026-09-22 13:01:46 +08:00
Miayui 4d99702acd feat(firmware): support rocking-horse workbench delivery 2026-09-22 11:15:13 +08:00
Miayui ba5dfcd6da feat(web): add peripheral card quick controls 2026-09-21 16:01:42 +08:00
Miayui ad0c1f4ed1 feat(scenes): add deterministic romantic cinema template 2026-09-21 12:07:49 +08:00
Miayui b3bd90f748 fix(web): reveal NFC peripherals after detection 2026-09-21 11:04:18 +08:00
Miayui 448c8b31a0 feat: improve mode and peripheral orchestration 2026-09-21 00:13:47 +08:00
Miayui 89707a42e2 feat: make mode creation preview-only and cache-safe 2026-09-20 19:32:03 +08:00
Miayui bfff1b515c feat: integrate learned peripherals into mode creation
Expose safe NFC-bound infrared actions to semantic scene composition, require flashing-light actions for romantic and Mid-Autumn modes, and preserve mixed CAN/IR mode flows.

Fix NFC device reassignment and retry extension/toy MQTT publication when an active card's binding catalog changes.
2026-09-20 16:37:19 +08:00
Miayui 904f5f8a6c docs: streamline project README 2026-09-18 14:48:07 +08:00
Miayui 24aa1371dc fix: optimize firmware notification action UI 2026-09-18 13:22:05 +08:00
Miayui 0ab1d76781 feat: notify vehicle of published firmware 2026-09-18 12:52:36 +08:00
Miayui e920a92505 Fix workbench dark theme and enlarge dialogs 2026-09-18 09:11:59 +08:00
Miayui c812a1a595 Merge origin/feature/N7Web into feature/N7Web
Integrate the remote firmware workbench, OTA delivery, and timed mode schedules while preserving the local N7 scene and runtime changes.
2026-09-17 12:46:51 +08:00
Miayui 7d196db02b feat(n7web): sync N7 scene cards and optimize runtime 2026-09-17 11:04:05 +08:00
vee ab8e43050c feat: add seat firmware workbench and OTA delivery 2026-09-16 17:55:30 +08:00
Miayui 75ce391312 feat(n7web): refine spatial UI and NFC-gated extension routing 2026-09-16 16:14:10 +08:00
Miayui 0e4328f548 feat(n7web): enhance control workflows and status feedback 2026-09-16 12:52:07 +08:00
Miayui 84a3cd73bb feat(web-console): unify spatial UI and chat reset 2026-09-14 18:48:11 +08:00
Miayui 58f1a2da42 feat(n7web): add pet settings and day night themes 2026-09-14 17:12:08 +08:00
Miayui aa6f59d3a6 feat(n7web): add pet NFC workflow and glass UI 2026-09-14 15:16:02 +08:00
vee 3eeca2ea75 merge: integrate remote MQTT routing with seat-scoped dynamic modes 2026-09-12 15:10:16 +08:00
vee d8dad22ec0 feat: add seat-scoped static and dynamic assistant modes 2026-09-12 14:56:30 +08:00
vee d09b5967f8 fix: restore mode clicks and compose seat-aware assistant scenes 2026-09-12 13:33:38 +08:00
Miayui 812e67422d 优化后端 2026-09-11 17:56:56 +08:00
Miayui 7eed11b774 mqtt切换为远程服务器部署,优化简单指令解析时长 2026-09-11 10:26:23 +08:00
Miayui 3fc6f2c2a4 mqtt切换为远程服务器部署,优化简单指令解析时长 2026-09-11 10:22:24 +08:00
Miayui 51ca12b624 添加模式卡片下发 2026-09-09 14:37:25 +08:00
vee 76057be044 fix: execute mode cards through deterministic assistant route 2026-09-09 09:04:55 +08:00
vee 2af8b8f9ac feat: route mode cards through AI assistant 2026-09-09 08:32:51 +08:00
vee 93103c2579 feat: integrate production cockpit AI assistant 2026-09-08 17:46:07 +08:00
vee 5e228e12ae fix: harden conversational mode draft revisions 2026-09-08 00:55:45 +08:00
vee f0a248ede3 fix: support conversational mode draft updates 2026-09-07 23:59:42 +08:00
vee 6b2b644ee6 fix: compose named cockpit modes from capabilities 2026-09-07 20:52:19 +08:00
vee cf9b89508c fix: configure Codex provider authentication 2026-09-07 18:17:10 +08:00
vee 53d80ab662 test: cover assistant stream deployment failures 2026-09-07 17:54:15 +08:00
vee c96970f068 feat: 完善 N7 智能座舱交互与控制链路 2026-09-07 15:34:59 +08:00
minghanchen 32d179143f 优化语音输入/普通文本输入 2026-09-03 13:36:40 +08:00
minghanchen 009d8ca73e 优化后端和前端逻辑 2026-09-03 10:21:01 +08:00
minghanchen ccd64adcfe 优化的前端UI 2026-09-02 11:39:31 +08:00
minghanchen 00c7d6b874 merge: integrate remote speech capabilities into feature/N7Web 2026-09-01 17:01:50 +08:00
minghanchen cbf230182d 完善模式卡片、NFC及多设备控制流程 2026-09-01 16:24:26 +08:00
minghanchen ca26259e63 添加NFC绑定红外设备方式,融合车端设备和扩展设备统一控制 2026-09-01 11:04:59 +08:00
minghanchen 6d75944c0f 添加红外扩展 2026-08-28 17:27:43 +08:00
vee aa12135b22 feat: integrate Chinese speech understanding and capability routing 2026-08-28 15:19:44 +08:00
minghanchen fd13d9f1ee Merge branch 'feature/N7Web' of https://git.kendeji.fun/vee/Cognitive_Agent into feature/N7Web
# Conflicts:
#	apps/web-console/app.js
#	apps/web-console/index.html
#	apps/web-console/service-worker.js
#	src/cognitive_agent/server.py
#	tests/e2e/test_seat_web_api.py
2026-08-27 14:43:44 +08:00
minghanchen 4a0570706f 优化无线/有线CAN,以及控制指令解析 2026-08-26 20:11:09 +08:00
vee 2529b4896a feat: integrate NFC infrared peripherals and orchestration 2026-08-25 11:50:49 +08:00
minghanchen 9ac8fdface 优化了副驾座椅控制 2026-08-25 11:39:41 +08:00
minghanchen 33c92d1352 优化后端和前端 2026-08-24 14:15:53 +08:00
7597 changed files with 1916742 additions and 4396 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.venv
.env
.env.auth
__pycache__
*.py[cod]
artifacts
output
tmp
tests
firmware
docs/product
+15
View File
@@ -8,9 +8,15 @@ artifacts/mode-profiles/
artifacts/server/
artifacts/tmp/
artifacts/self-repair/
artifacts/models/
artifacts/*.log
tmp/*.log
.codex_tmp/
/build/
/output/
/apps/web-console/node_modules/
/apps/web-console/dist/
/tmp/imagegen/
firmware/t2g/Seat_Controller_demo/Tests/manual_hil/logs/
firmware/t2g/Seat_Controller_demo/Tests/manual_hil/seat_function_runtime/
tools/zcan/seat_function_runtime/
@@ -26,4 +32,13 @@ firmware/t2g/Seat_Controller_demo/IAR/rev_d/.ninja_log
firmware/t2g/Seat_Controller_demo/IAR/rev_d/BrowseInfo/
firmware/t2g/Seat_Controller_demo/IAR/rev_d/List/
firmware/t2g/Seat_Controller_demo/IAR/rev_d/Obj/
firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/settings/
firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_demo.eww
firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/.Passenger_Seat_Timed_Controller_demo_build_cache
firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/.ninja_deps
firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/.ninja_log
firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/BrowseInfo/
firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/List/
firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/Obj/
firmware/t2g/tviibh8m/src/examples/crypto/ClientRunsOnCM7_0/RSA/key_data/private_key*.pem
artifacts/logs/
+28 -5
View File
@@ -1,14 +1,37 @@
.PHONY: validate test dev dev-lan
.PHONY: validate test test-frontend dev dev-lan can-protocol-generate can-protocol-check can-read-trans can-read-trans-test
validate:
CAN_READ_TRANS_BUILD_DIR ?= build/can_read_trans
CAN_READ_TRANS_CANFD_ROOT ?= /home/orin/project511/project-code/canfd
TEST_PYTHONPATH := src:domains/_template/src:domains/ambient_light/src:domains/climate/src:domains/roof/src:domains/seat/src
can-protocol-generate:
python tools/generate_can_semantic_protocol.py
can-protocol-check:
python tools/generate_can_semantic_protocol.py --check
validate: can-protocol-check
python tools/validate_repository.py
test:
PYTHONPATH=src:domains/seat/src python -m unittest discover -s domains/seat/tests -p 'test_*.py'
PYTHONPATH=src:domains/seat/src python -m unittest discover -s tests -p 'test_*.py'
test-frontend:
python tools/run_node.py --test tests/unit/assistant_stream_runtime.test.cjs
python tools/run_node.py --test tests/unit/speech_input_runtime.test.cjs
python -m pytest tests/unit/test_energy_monitor_frontend_contract.py -q
test: can-protocol-check test-frontend
PYTHONPATH=$(TEST_PYTHONPATH)$${PYTHONPATH:+:$${PYTHONPATH}} python -m pytest
dev:
PYTHONPATH=src:domains/seat/src python -m cognitive_agent.server
dev-lan:
PYTHONPATH=src:domains/seat/src python -m cognitive_agent.server --host 0.0.0.0
can-read-trans: can-protocol-generate
cmake -S tools/can_read_trans -B $(CAN_READ_TRANS_BUILD_DIR) \
-DCMAKE_BUILD_TYPE=Release \
-DCANFD_ROOT=$(CAN_READ_TRANS_CANFD_ROOT)
cmake --build $(CAN_READ_TRANS_BUILD_DIR) --parallel
can-read-trans-test: can-read-trans
ctest --test-dir $(CAN_READ_TRANS_BUILD_DIR) --output-on-failure
+248 -107
View File
@@ -1,146 +1,287 @@
# Cognitive Agent
面向车载执行器的认知编排 Agent 单仓工程。系统把自然语言请求转换为可校验的语义配置,再由确定性的领域插件编译为 CAN 命令计划,最后经安全策略和执行器下发。
面向车载执行器的认知编排单仓工程。系统把自然语言或结构化请求转换为可校验的语义配置,由领域插件确定性编译为 `CommandPlan`,经过平台安全门禁后以 dry-run、模拟台架或真实 CAN 方式执行,并保存可审计结果。
启动前端:PYTHONPATH=src:domains/seat/src .omx/python311-venv/bin/python -m cognitive_agent.server
当前仓库以腾势 N7 / T2G 座舱演示和硬件台架为主。默认环境不会打开真实硬件;气候、天幕和氛围灯领域的矩阵目前也只允许虚拟执行。
## 建设阶段
## 能做什么
- 阶段一:每个领域独立完成能力定义、功能配置生成、配置校验、CAN 编译和单执行器测试。
- 阶段二:由中央编排器生成 `SceneConfig`,引用多份 `FunctionConfig`,按依赖关系、时间偏移和失败策略组合执行。
- 最终形态:Web 控制台接收一句话请求,展示结构化决策摘要、配置文件、校验结果和执行状态。
- 同源 Python HTTP 服务与 Vue 3 + Vite Web 控制台。
- 能力目录驱动的自然语言编排、模式卡片、跨域场景和 NDJSON 助手流。
- `IntentIR`、`FunctionConfig`、`SceneConfig`、`CommandPlan`、`TraceEvent` 和 `ExecutionReport` 公共契约。
- 座椅、空调、天幕、氛围灯四个领域插件,以及平台侧门窗、插座、阅读灯等原生 CAN 能力。
- 虚拟 CAN、模拟硬件、T2G ZLG CAN 台架和双通道车身 CAN 接入。
- 语音输入、MediaPipe 手势、NFC/红外外设、模式目录同步和只读固件 OTA 服务。
## 核心约束
AI 模型只能提出受契约约束的语义计划,不能生成或发送原始 CAN 帧。真实执行仍由服务端的能力白名单、参数 Schema、硬件授权、安全策略、互斥和反馈检查共同决定。
1. LLM 只能生成 `IntentIR`、`FunctionConfig` 或 `SceneConfig`,不能直接生成或发送原始 CAN 帧。
2. `FunctionConfig` 只允许包含一个 `domain`;跨域需求必须使用 `SceneConfig` 引用多份领域配置。
3. CAN 编译必须是确定性的:相同配置、相同平台标定和相同插件版本必须生成相同 `CommandPlan`。
4. DBC、通道定义和安全标定由集成人员在 `platforms/` 统一维护;领域成员只维护语义信号映射。
5. 所有真实执行必须经过公共 Schema 校验、领域校验、安全策略、dry-run 和审计记录。
6. UI 展示可审计的决策摘要和阶段事件,不展示模型私有思维链。
## 目录
## 处理链路
```text
.
├── apps/ # API 服务、Web 控制台和内置手势模型资源
├── artifacts/ # 每次请求的生成物和执行记录(不入库)
├── config/ # 全局环境和策略配置
├── contracts/ # 冻结的公共 Schema、示例和版本规则
├── deploy/ # 容器及部署资源
├── docs/ # 架构、流程、治理和 ADR
├── firmware/t2g/ # 仓库内统一座椅 IAR 工程与可烧录产物
├── domains/ # 可独立开发、测试、交付的领域插件
│ ├── _template/ # 新领域模板
│ ├── seat/ # 座椅
│ ├── climate/ # 空调
│ ├── ambient_light/ # 氛围灯
│ └── roof/ # 天幕/遮阳帘/调光玻璃
├── platforms/ # 车型、DBC、总线和安全标定
├── src/cognitive_agent/ # 中央 Agent、编排器、运行时和基础设施
├── tests/ # 跨域契约、集成和端到端测试
└── tools/ # 脚手架、校验和开发工具
```
完整依赖关系和处理链路见 [总体架构](docs/architecture/overview.md),成员交付步骤见 [领域交付流程](docs/workflows/domain-delivery.md),修改边界见 [所有权规则](docs/governance/ownership.md)。
整体排期和阶段准入条件见 [集成路线](docs/workflows/integration-roadmap.md)。
## 配置处理链路
```text
用户文本
-> GPT + Capability Registry + 当前会话状态
-> 通用能力请求(领域、能力、作用域、操作和参数)
-> IntentIR(意图抽取、领域识别、歧义与置信度)
-> 单领域:FunctionConfig
-> 多领域:SceneConfig + N 个 FunctionConfig
-> 公共 Schema 校验 + 领域语义校验
用户请求
-> 能力目录 / 会话状态 / Codex 或本地解析
-> IntentIR
-> FunctionConfig(单领域)或 SceneConfig(跨领域)
-> 公共 Schema + 领域语义校验
-> 领域 Compiler -> CommandPlan
-> 平台安全校验 -> 调度 -> CAN Gateway
-> 回读/超时/结果 -> TraceEvent + ExecutionReport
-> 平台安全校验 -> dry-run / simulated_hardware / physical CAN
-> 回读、超时和审计 -> TraceEvent + ExecutionReport
```
运行时产物统一保存在:
运行时产物位于 `artifacts/runs/<request_id>/`,典型结构如下:
```text
artifacts/runs/<request_id>/
├── intent.yaml
├── scene.yaml # 仅跨域请求存在
├── functions/<domain>.yaml
├── command-plans/<domain>.yaml
├── trace.jsonl
└── execution-report.yaml
intent.yaml
scene.yaml # 跨域请求才有
functions/<domain>.yaml
command-plans/<domain>.yaml
trace.jsonl
execution-report.yaml
```
`artifacts/` 中禁止写入 API Key、完整隐私文本或未脱敏的车辆标识。
不要在 `artifacts/` 写入 API Key、完整隐私文本或未脱敏车辆标识。
## 可执行领域
## 当前能力边界
座椅与空调等车身设备使用同一 ZLG CAN 盒双通道联动时,参见 [T2G 座椅与车身双通道 CAN 联动](docs/workflows/t2g-cabin-dual-channel-can.md)。
| 能力面 | 当前状态 | 事实来源 |
| --- | --- | --- |
| `seat` | 虚拟矩阵闭环;T2G 台架使用独立硬件映射和校准 | [`domains/seat/`](domains/seat/)、[台架流程](docs/workflows/t2g-seat-hardware-bench.md) |
| `climate` | dry-run;支持分区温度、电源、风量、A/C、循环、风向和前除霜 | [`domains/climate/`](domains/climate/) |
| `roof` | dry-run;支持透光率、后遮阳帘位置和自动调光 | [`domains/roof/`](domains/roof/) |
| `ambient_light` | dry-run;支持开关、RGB、亮度和矩阵场景模式 | [`domains/ambient_light/`](domains/ambient_light/) |
| 原生车身 CAN | 按平台和运行时能力目录启用,不等同于领域插件 | [`config/native_can_capabilities/`](config/native_can_capabilities/)、[`platforms/`](platforms/) |
| 模式卡片 | `config/scene_templates/` 提供内置模板;用户模式持久化到 `artifacts/mode-profiles/` | [模式时间轴](docs/mode-schedules.md) |
当前已接入四个相互隔离的可执行领域:
`platforms/reference_vehicle` 的 `execution_enabled` 为 `false`。`platforms/t2g_seat_bench` 和 `platforms/t2g_cabin_bench` 才是仓库内允许配置真实执行的台架平台,但仍需硬件开关、授权令牌、总线配置和反馈校验全部满足。
- 座椅:所有热管理、位置移动和姿态预设能力仅面向副驾;未指定座位时默认副驾,显式主驾请求拒绝执行。
- 空调:基于 `空调-天幕-氛围灯CAN信号矩阵表.xlsx` 的电源、18–32℃ 分区温度、0–7 档风量、A/C、内外循环、风向和前除霜。
- 天幕:基于同一矩阵的 0–100% 透光率、后遮阳帘绝对位置和自动调光。
- 氛围灯:基于同一矩阵的总开关、24-bit RGB、0–100% 亮度和 ECU 场景模式。
## 快速开始
一句话只涉及一个领域时直接生成一份 `FunctionConfig`;涉及多个领域时生成一份 `SceneConfig` 引用各领域独立的 `FunctionConfig`,并由各自编译器产生 `CommandPlan`。三份新增矩阵仍缺少车型适用性、完整计数器、校验和和发送周期,因此与座椅演示一样只允许虚拟执行。
### 环境要求
- Python 3.11 或更高版本。
- Node.js 与 npm(构建 Web 控制台)。
- Codex CLI `0.150.0-alpha.8`,用于内置 AI 助手。
- 真实台架另需 ZLG 驱动/桥接程序、对应 DBC、IAR 工具链和硬件授权。
### 安装(PowerShell)
```powershell
py -3.11 -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install -e ".[test,dev,api,can,llm,ir]"
cd apps\web-console
npm ci
npm run build
cd ..\..
npm install --global @openai/codex@0.150.0-alpha.8
codex login status
```
语音识别是可选安装项:
```powershell
.\.venv\Scripts\python.exe -m pip install -e ".[speech]"
.\.venv\Scripts\python.exe tools\install_sensevoice_model.py
```
配置模型认证时使用 `OPENAI_API_KEY`,或让 Codex CLI 使用已登录账户。`.env` 和 `.env.auth` 已被 Git 忽略;可参照 [`config/environments/`](config/environments/) 中的示例配置。不要把密钥提交到仓库。
### 启动完整本地演示
推荐使用启动器。它会管理主服务、只读模式卡片服务和只读 OTA 服务:
```powershell
.\.venv\Scripts\python.exe tools\start_n7web.py --skip-web-build
```
默认端口:
| 服务 | 地址 | 说明 |
| --- | --- | --- |
| 主服务和 Web 控制台 | `http://127.0.0.1:8000` | API、控制台、助手和车控入口 |
| 模式卡片目录 | `http://127.0.0.1:8090/n7/scenes` | HTTP/UDP 只读目录服务 |
| 固件 OTA | `http://127.0.0.1:8012` | 只读发布清单和下载 |
启动前端构建:
```powershell
.\.venv\Scripts\python.exe tools\start_n7web.py
```
启动器支持 `--host`、`--port`、`--scene-port`、`--ota-port`、`--catalog`、`--server-id`、`--python` 和 `--dry-run`。使用 `--host 0.0.0.0` 前请确认网络可信,并为公网部署启用认证和 HTTPS。
健康检查:
```powershell
Invoke-RestMethod http://127.0.0.1:8000/api/health
Invoke-RestMethod http://127.0.0.1:8000/api/assistant/status
```
`/api/health` 依赖 Codex App Server 就绪;助手进程不可用时返回 HTTP `503`,不会伪装成可用服务。
### 只启动主服务
需要自行运行前端开发服务器或不需要模式目录/OTA 服务时,可直接运行:
```powershell
$env:PYTHONPATH = "src;domains/seat/src"
.\.venv\Scripts\python.exe -m cognitive_agent.server
```
前端热更新:
```powershell
cd apps\web-console
npm run dev
```
Vite 使用 `http://127.0.0.1:5173`,并把 `/api` 与 `/artifacts` 代理到 `http://127.0.0.1:8000`。生产构建输出为 `apps/web-console/dist`,主服务检测到该目录后优先提供构建产物。
在已安装 GNU Make 的环境中,`make dev` 等价于启动主服务;Windows PowerShell 推荐使用上面的显式 Python 命令,以确保使用仓库的 `.venv`。
## 开发与验证
先激活 `.venv`,或把下面的 `python` 替换为 `.\.venv\Scripts\python.exe`。仓库的 `Makefile` 使用 POSIX 风格环境变量,在 Windows 上也可以直接调用 Python 脚本。
```bash
# 公共 Schema、领域清单、平台资源和生成 CAN 协议
make validate
# 全量 Python、前端契约和 CAN 协议回归
make test
make dev
# 仅前端运行时契约
make test-frontend
# 生成或检查共享 CAN 语义协议
make can-protocol-generate
make can-protocol-check
```
浏览器访问 `http://127.0.0.1:8000`。设置 `OPENAI_API_KEY` 后,GPT Structured Outputs 会基于各领域的 `config/capabilities.yaml` 和当前会话状态直接生成通用能力请求;后端只做结构校验、领域路由和确定性执行适配,不用本地语义规则改写 GPT 动作。本地解析器用于未配置 GPT,或 GPT/`auto` 模式因 API Key、模型权限、网络或结构化响应异常而失败时的安全降级;结果会明确标记 `local_rules_fallback`。模型由 `OPENAI_MODEL=gpt-5.6-sol` 指定;兼容 OpenAI Responses API 的服务可通过 `OPENAI_BASE_URL` 指定。无论使用哪种解析器,配置校验、CAN 编译和虚拟执行均为同一确定性路径。
不使用 `make` 时:
### 一句话、快捷模式与手势控制
首页左侧提供一句话控制,可同时描述座椅、空调、天幕和氛围灯目标;Enter 直接执行,Shift+Enter 换行。观影、午休和零重力是可直接调用的快捷模式,“保存当前”可把当前副驾驶姿态保存为命名模式。复杂跨域自然语言优先使用 GPT 解析,本地规则用于已明确覆盖的离线指令。
顶部“手势控制”打开同页抽屉。浏览器在本机完成 MediaPipe 推理,识别结果仍通过同一自然语言编排、安全校验与执行链路处理。运行必需资源已经纳入本工程:
```text
apps/web-console/assets/gesture/gesture_recognizer.task
config/gesture/default.yaml
```powershell
.\.venv\Scripts\python.exe tools\validate_repository.py
.\.venv\Scripts\python.exe -m pytest
```
因此源码打包和容器构建不依赖相邻的 `seat_gesture_control` 目录。模型文件只保留一份,不包含原工程的测试视频、日志或验证输出。浏览器摄像头要求安全来源,本地使用 `http://127.0.0.1:8000`,远程部署使用 HTTPS;首次使用仍需浏览器授予摄像头权限。MediaPipe Web 运行时从 jsDelivr 按需加载,视频帧不上传到后端。
仓库配置了 `pytest` 的 `contract`、`integration` 和 `hardware` markers。默认测试使用虚拟或模拟执行;真实台架测试必须按 [T2G 前端测试集](docs/testing/t2g-seat-frontend-test-suite.md) 的顺序执行,并显式确认硬件。
手势安全状态机采用张掌保持 1 秒激活、动作保持 600 ms 且至少 8 帧确认、单次运动 1.5 秒上限、连续 3 秒无有效动作自动锁止。换动作不需要重新张掌;握拳立即停止当前动作但保持激活;多手画面不执行动作。手势映射可在抽屉内自定义并保存在当前浏览器。
同一局域网的其他电脑需要体验时,使用 `make dev-lan` 监听全部网络接口。服务启动日志会打印实际 LAN 地址,例如 `http://172.20.10.5:8000`。前端和 API 使用同源访问,不需要 CORS,也不会把 `OPENAI_API_KEY` 发送到浏览器。若没有启用 `.env.auth`,任何可达设备都能触发服务端 GPT 请求,因此未认证的 LAN 模式只应在可信网络内临时演示。
Linux 上使用 USBCANFD-200U 被动同步 CH1 车辆状态时,使用
`zlgcan-linux` 后端。USB 权限、60 MHz/500k/2M 位时序、只读配置和状态接口
验证见 [Linux ZLG 车辆状态接入](docs/workflows/linux-zlg-vehicle-state.md)。
同一页面也可选择本机 NanoMQ 无线链路,其中 CAN0 对应副驾座椅、CAN1 对应
其余车辆状态和控制;MQTT 主题、原始 CAN 帧 JSON 合约和有线/无线共用安全
门禁见同一文档。
## 认证与公网体验
公网演示必须启用 `.env.auth` 中的服务端认证。密码使用 PBKDF2-SHA256 哈希保存;认证成功后签发有有效期的 HMAC 会话 Cookie,Cookie 使用 `HttpOnly` 和 `SameSite=Strict`,经 HTTPS 代理时自动增加 `Secure`。登录失败按来源 IP 限流,GPT 请求按体验账号限制每分钟调用次数。未登录访问页面会跳转 `/login`,未登录 API 请求返回 `401`。
生成认证材料:
构建双通道 CAN 读取工具需要 CMake 和外部 CANFD SDK:
```bash
PYTHONPATH=src python -m cognitive_agent.auth hash-password
PYTHONPATH=src python -m cognitive_agent.auth generate-secret
make can-read-trans
make can-read-trans-test
```
固定公网演示部署在 `https://511.kendeji.fun`。服务器使用 Docker Compose
运行应用,容器端口只绑定 `127.0.0.1:8511`,由 Caddy 终止 HTTPS 并反向
代理。部署结构、更新和运维命令见
[`deploy/server/README.md`](deploy/server/README.md)。本地 `make dev` 与
`make dev-lan` 仍分别用于回环地址和可信局域网开发。
## API 入口
当前 dry-run 规划策略允许 GPT 在没有同名 preset 时,将可理解的模式目标分解为已注册原子能力并生成保守候选参数。候选值只用于模型理解演示,不能替代实车模式标定。
浏览器只访问同源服务。下面是主要集成面,完整路由以 [`src/cognitive_agent/server.py`](src/cognitive_agent/server.py) 为准:
靠背/坐垫与坐垫/腿托支持演示级精确夹角,并可将当前五轴姿态保存为会话持久化的命名 `FunctionPreset`。保存操作不发 CAN;再次调用时先展开为五个原子电机动作,再进入同一编译、校验和虚拟执行链路。
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/api/health` | 服务与助手健康状态 |
| `GET` | `/api/runtime` | 当前运行时、平台和执行模式摘要 |
| `GET` | `/api/assistant/status` | Codex App Server 状态 |
| `GET` | `/api/assistant/capabilities` | 权威能力目录快照 |
| `POST` | `/api/requests` | 提交自然语言或结构化车控请求 |
| `POST` | `/api/requests/preview` | 只规划/校验,不执行 |
| `POST` | `/api/assistant/stream` | AI 助手 NDJSON 流 |
| `GET` | `/api/hardware/seat-state` | 座椅状态快照 |
| `GET` | `/api/hardware/vehicle-state` | 车身状态快照 |
| `GET` | `/api/vehicle-display-sync` | 网页与车机任务同步 |
| `GET` | `/n7/scenes` | 只读模式目录 |
完整能力边界、矩阵解释和验收用例见 [座椅阶段一交付](docs/workflows/seat-stage1-delivery.md)。
`/api/internal/codex/vehicle-control` 仅供本机 Codex MCP 回调使用,需要一次性令牌和原始请求绑定,不是浏览器或第三方 API。语音、手势、NFC、固件工作台和自修复接口也由服务端权限门禁保护。
已验证的 T2G 加热、通风、四电机和原生动态模式能力已完整纳入 `firmware/t2g`,包括 IAR 工程及仓库内 `common/tviibh8m` 构建依赖。M0/M1/M2/M3 分别对应水平、坐盆、靠背和腿托;revision 13通过`0x306`反馈带斜向耦合补偿的木马模式与四轴全程联动动态摇篮的阶段/循环状态。木马模式围绕中间姿态在`[270,250,460,400]`和`[370,130,540,460]`之间连续俯仰往复:坐盆峰峰行程120 Hall、约18 mm垂直分量,水平峰峰行程100 Hall、90%巡航Duty,用额外水平行程抵消坐盆斜向运动约16.4 mm的反向水平分量;参数集中在`seat_dynamic_mode_config.h`并由N7长期库镜像。动态模式收到一次启动命令后由ECU连续循环,收到停止/全停命令或累计运行10分钟时停止;目标到位式阶段在底层堵转/无运动保护继续生效的前提下使用独立保护窗口。真实硬件请求先被动核对新版周期反馈与 480/374/917/647 Hall 量程,未检测到匹配固件时默认通过 IAR C-SPY 自动烧写并复验,再发送 CAN 并等待真实回读。当前前端、能力注册、编译、安全门禁和状态反馈只允许副驾 `passenger/front_right`;固件中的 `Driver_Seat_*` 仅是板端既有协议字段名。构建、ZLG bridge、接线、安全门禁、前端测试集和已知量程归零见 [T2G 座椅真实硬件台架接入](docs/workflows/t2g-seat-hardware-bench.md) 与 [前端能力测试集](docs/testing/t2g-seat-frontend-test-suite.md)。
## 配置与安全
默认 [`config/default.yaml`](config/default.yaml) 使用 `dry_run`、结构化输出和 `artifacts/runs`。真实硬件必须同时满足:
1. 平台 manifest 的 `execution_enabled: true`。
2. 对应 CAN 后端、通道、DBC 和校准可用。
3. 服务端硬件开关、授权令牌和安全策略通过校验。
4. 领域映射明确允许硬件执行,并收到真实反馈。
模拟台架显式使用:
```powershell
$env:COGNITIVE_AGENT_SIMULATED_HARDWARE_ENABLED = "true"
```
模拟结果会标记为 `simulated_hardware`,不会打开真实 CAN、ZLG 或 IAR。真实 T2G 座椅需要参考 [`config/environments/t2g_seat_bench.example.yaml`](config/environments/t2g_seat_bench.example.yaml) 和 [硬件台架流程](docs/workflows/t2g-seat-hardware-bench.md);不要仅通过前端按钮声称已经获得硬件权限。
语音和摄像头只在安全来源工作:本机使用 `http://127.0.0.1:8000`,远程部署必须使用 HTTPS。局域网开发命令如下:
```bash
make dev-lan
```
该模式会监听全部网络接口,任何可达设备都可能触发服务端请求,只适合可信网络临时演示。
## 代码结构
```text
apps/web-console/ Vue 入口、兼容运行层、静态资源和 PWA
src/cognitive_agent/ HTTP 服务、应用编排、运行时、基础设施和助手适配
domains/*/ 领域插件:能力、Schema、映射、编译器和测试
platforms/*/ 车型、DBC、总线和校准;定义执行边界
contracts/schemas/ 冻结的公共 JSON Schema
config/ 环境、策略、车辆、模式和原生能力配置
tests/ 跨域契约、集成、端到端和前端测试
tools/ 启动器、仓库校验、协议生成、HIL 和诊断工具
firmware/t2g/ T2G 固件工程和可烧录依赖
deploy/ Docker Compose、服务器和 Kubernetes 占位配置
docs/ 架构、决策、治理、工作流和测试说明
artifacts/ 本地运行产物、模型、HIL 报告和持久化数据
```
依赖边界:领域插件只依赖 SDK 和公共契约,不直接调用 LLM、HTTP 或 CAN;编排层只组合语义配置,不按 CAN signal 编排;DBC 和安全标定由 `platforms/` 统一维护。详见 [总体架构](docs/architecture/overview.md)、[领域交付流程](docs/workflows/domain-delivery.md) 和 [所有权规则](docs/governance/ownership.md)。
## 可选能力
### 语音
安装 `.[speech]` 并运行 `tools/install_sensevoice_model.py` 后,控制台支持手动录音和唤醒词待机。预览和最终识别接口为 `/api/speech/preview` 与 `/api/speech/transcribe`;录音不持久化。调试使用 `SPEECH_DEBUG=1`,默认不向浏览器返回 trace。
### 手势
手势模型随仓库提供:`apps/web-console/assets/gesture/gesture_recognizer.task`。推理在浏览器完成,视频帧不上传;需要摄像头权限和安全来源。
### NFC / 红外外设
安装 `.[ir]` 后运行主服务。NFC、GSCU1B TCP 网关、红外槽位目录和 MQTT 回退由 `/api/extension/*` 管理;学习、绑定和发射均受服务端能力目录与持久 NFC 绑定约束。
### Android PAD
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\android_pad_dev.ps1
powershell -ExecutionPolicy Bypass -File .\tools\android_pad_dev.ps1 -Stop
```
脚本使用模拟台架,不打开真实硬件执行。
## 部署
服务器 Docker Compose 和 Caddy 反向代理说明见 [`deploy/server/README.md`](deploy/server/README.md)。镜像默认以非 root 用户、只读根文件系统运行,应用只绑定服务器回环地址,再由 Caddy 提供 HTTPS。部署时通过私密环境文件注入认证材料和模型凭据,不把 `.env`、`.env.auth`、Codex `auth.json` 或 API Key 放入镜像和版本库。
`deploy/docker/` 记录后续拆分 API/worker/Web 的边界;`deploy/k8s/` 目前是 Kubernetes 资源占位文档。真实 CAN 设备只能挂载到明确授权的执行器环境。
## 相关文档
- [总体架构](docs/architecture/overview.md)
- [T2G 座椅与车身双通道 CAN](docs/workflows/t2g-cabin-dual-channel-can.md)
- [T2G 座椅真实硬件台架](docs/workflows/t2g-seat-hardware-bench.md)
- [T2G 座椅前端测试集](docs/testing/t2g-seat-frontend-test-suite.md)
- [中文语音控制链路](docs/speech-recognition.md)
- [模式卡片与时间轴](docs/mode-schedules.md)
- [领域交付流程](docs/workflows/domain-delivery.md)
- [所有权与变更边界](docs/governance/ownership.md)
- [服务器部署](deploy/server/README.md)
- [Web 控制台细节](apps/web-console/README.md)
## 项目状态
这是面向演示、集成和受控台架验证的工程,不是未经适配即可部署到任意车型的通用车控产品。提交修改前至少运行 `validate` 和相关领域测试;涉及 DBC、平台标定、硬件执行或公共 Schema 的变更,需要同时更新对应专题文档与回归用例。
+142 -4
View File
@@ -1,11 +1,73 @@
# Web Console
## 创建模式时的外设动作
创建模式时,每个选中的外设只保留一条已授权的开启指令,不加入灯光颜色、亮度或效果设置。
浪漫、约会、纪念日及浪漫影院等模式仍会打开灯笼、蜡烛和灯带,执行时也不会再自动补上红色。
缺少已授权开启能力的外设不能使用调色指令替代。此规则作用于模式创建与自动补齐,
独立的外设调色指令以及车内原生座椅、空调等控制沿用原有逻辑。
## 车端影院模式的副驾姿态
从 `from_n7/scenes` 同步的 `local_scene.seat_preset` 会在执行时按 N7 的 M0–M3
标定转换为副驾前后、坐盆、靠背、腿托四项位置控制,沿用现有座椅安全校验和执行器。
例如 `[246, 112, 405, 342]` 脉冲转换为当前百分比协议的 `[51, 30, 44, 53]`。
原始脉冲保留在卡片中,已保存的影院模式及继承该预设的本地模式无需重新上传。
不完整、越界或错误座位的预设会被拒绝;带延时的预设继续交由车端处理。
视频启动等车端专属动作仍走 `transto_n7/ctl`,不会重复转发已由 PC 处理的四轴姿态。
模式卡片区分完整成功、部分处理、指令已提交待车端确认及模拟校验,消息入队不会显示为
车辆执行完成。
## 座椅固件开发工作台
“开发指令”支持输入“增加动态木马功能”,也可点击位于位置控制、座椅通风、座椅加热之后的
“+ 动态木马”快捷按钮。木马模式会自动加入位置控制依赖,基于当前时间控制基线生成
“时间控制+位置控制+动态木马”固件,并沿用代码生成、IAR 编译、验证、打包和发布流程。
可同时选择通风、加热等能力。固件使用已有木马标定参数及停止、超时保护。
发布后的产物可下载,并通过该行的“通知车端”按钮发送现有协议的 3 帧发布通知;
通知仅提示有可用固件,不会自动升级或启动座椅。未选择木马的工作台任务关闭木马编译开关。
## 宠物卡片与 NFC 上报
主页“宠物卡片”区域支持创建多张宠物卡片。`pet_name` 与 `action` 可在卡片中编辑,
`nfc_id` 为只读字段,只能在“NFC 设备授权”弹窗中使用读卡器当前识别到的实体 NFC
完成绑定,不能由浏览器或 API 请求直接填写。同一个 NFC UID 最多绑定一张宠物卡片;重新
绑定时会自动解除旧卡片上的相同 UID。新卡片必须先完成编辑并点击保存,之后才能从
“NFC 设备绑定”或“关联卡片”入口继续绑定;右侧卡片区域不再直接触发 NFC 绑定。
卡片由后端持久化到 `artifacts/server/pet_cards.sqlite3`,网页关闭或服务重启不会丢失。
同源接口如下:
- `GET /api/extension/pets`:读取卡片及发布器状态。
- `POST /api/extension/pets`:创建卡片,只接受 `pet_name`、`action`。
- `PUT /api/extension/pets/{pet_id}`:修改宠物名和动作。
- `DELETE /api/extension/pets/{pet_id}`:删除卡片。
- `POST /api/extension/pets/{pet_id}/bind-current-nfc`:绑定读卡器中当前在场的 NFC。
- `POST /api/extension/pets/{pet_id}/unbind`:解除绑定。
服务端首次检测到已绑定 NFC 进入读卡区时,后台线程复用当前车辆 MQTT Broker 连接,
在 1 秒内向固定话题 `n7/pet` 发布 3 帧非保留消息,随后保持静默。只有将 NFC 移出读卡区
后再次靠近,才会触发下一组 3 帧。消息只包含以下三个字段:
```json
{"pet_name":"团团","nfc_id":"E113B203","action":"摇尾巴"}
```
发布行为独立于网页是否打开。若本次 3 帧中存在 Broker 发布失败,卡片与绑定关系仍会
保存,前端会显示本次突发发送失败;为避免持续发送,服务不会在 NFC 一直停留时无限重试,
移开并重新靠近后才会发起下一组 3 帧。
Web 控制台是车机端的远程自编排与同步显示界面。网页保留一句话指令和预设功能模式入口,请求通过同源服务发送至车机执行;同时同步显示车机任务、编排结果、设备反馈和 USB 扩展能力。真实 CAN 与硬件执行权限仍由服务端安全门禁控制。
当前页面包括:
- 自编排和自扩展合并为一个实时界面,登录身份固定为 `user`,不再提供模块切换。
- AI助手默认展开;左侧控制台、车端设备状态、外设与扩展能力默认收起。四个板块统一通过页面右上角的图标按钮展开或收回,主工作区随面板状态自适应。
- AI助手支持普通问答、工程能力核对和车控工作。能力问答读取运行时权威目录,高置信度冷热请求走确定性快速控制链,其他问题和复杂车控由 Codex App Server 流式处理;车控结果仍以自然语言和可审计的动作进度呈现。
- 网页端可输入一句话指令或点击预设模式,车机端同样可发起任务;两端任务与结果统一同步。
- AI助手语音入口支持双模式:手动点击麦克风按钮会立即以 `manual` 模式录音,不要求唤醒词;浏览器已授权麦克风、助手展开且页面可见时自动进入 `wake` 待机,只有听到“你好,小迪”(兼容无标点与字间停顿)后才展示并继续转录。AudioWorklet 采集后,服务端 SenseVoiceSmall 以节流预览返回未语义清洗的实时文字;静音端点立即停止采音,再由 Silero VAD、幂等 TranscriptAssembler 和理解层生成最终 `assistant_text`。普通问答、未知能力、否定和撤销表达均可回填,`commands` 仅作为受信任车控旁路,不能反向过滤助手输入。
- 整车数字孪生位于工作区中央,点击或悬停热点可查看车窗、遮阳帘、主/副驾座椅、阅读灯、氛围灯、前大灯和空调的完整状态。
- 车端设备状态与外设能力板块均可独立隐藏或恢复,面板状态在当前浏览器会话中保留。
- USB 外设接入后同步显示 GSCU 链路、外设名称、类型、已学习能力和最近一次跨设备联动。
@@ -13,12 +75,31 @@ Web 控制台是车机端的远程自编排与同步显示界面。网页保留
UI 只展示可审计的“决策摘要”和“处理轨迹”,不展示模型私有思维过程。
## AI助手接口
浏览器只使用同源接口,不直接连接 App Server、MCP 或模型服务:
- `GET /api/assistant/status`:返回 App Server 状态、可用性和当前会话数。
- `GET /api/assistant/capabilities`:返回由运行时注册表、模式目录、CAN 绑定和已学习外设共同生成的权威能力快照。`service_surfaces` 只提供语音、手势、NFC、自标定和自修复的状态信息,不代表助手可调用;执行资格以 `assistant_callable` 和服务端校验为准。
- `POST /api/assistant/stream`:提交 `message`、`conversation_id`、执行模式和当前车型等上下文,返回 `application/x-ndjson`。前端消费 `message.accepted`、`work.*`、`action.*`、`answer.delta`、`control.suggestions`、`answer.completed` 与 `error` 事件,以增量方式展示回复和控制进度。不支持的车控请求可随 `control.suggestions` 返回当前车型可执行的替代指令;前端去重后渲染为按钮卡片,点击会作为新指令沿同一受控链路提交。
`POST /api/internal/codex/vehicle-control` 是 App Server MCP 子进程专用的回环接口,要求一次性内部令牌且与原始用户文本绑定,不是浏览器或第三方集成接口。浏览器也不能通过响应内容扩大执行目标、切换执行模式或提交原始 CAN。
当前座椅阶段一控制台为无构建静态页面,由 `cognitive_agent.server` 同源提供 API 和资源。运行:
```bash
make dev
```
语音听写首次运行前安装运行时、官方完整精度模型和 Silero VAD:
```bash
python -m pip install -e ".[speech]"
python tools/install_sensevoice_model.py
```
录音只用于当前请求,不落盘;`captured_text` 保留完整声学结果,`raw_text` 在 `manual` 模式保留完整原文、在 `wake` 模式剥离唤醒词,`assistant_text` 是面向问答的轻清洗会话,`clean_text/control_text` 与结构化 Intent 则保持严格车控语义。前后端通过 `X-Speech-Input-Mode: manual|wake` 显式区分模式;可用 `SPEECH_WAKE_WORD_ENABLED=false` 关闭唤醒门,或用竖线分隔的 `SPEECH_WAKE_WORDS` 配置主唤醒词与别名。远程浏览器必须通过 HTTPS 才能访问麦克风。诊断可设置后端 `SPEECH_DEBUG=1` 并用 `?speechDebug=1` 打开前端日志,默认关闭。
Android PAD/模拟器联调使用:
```powershell
@@ -41,10 +122,54 @@ make dev-lan
其他设备访问启动日志打印的 `http://<LAN-IP>:8000`。页面与后端 API 由同一服务提供,无需单独配置前端地址。
自扩展接口统一位于 `/api/extension/*`,GSCU1B 默认以 TCP 模式监听
`0.0.0.0:4444`。可通过 `GSCU_TRANSPORT=simulation` 使用演示学习器,或通过
`GSCU_TRANSPORT=mqtt` 与 `GSCU_MQTT_*` 配置接入 MQTT。MQTT 依赖可通过
项目的 `ir` 可选依赖安装。自扩展不再需要独立的 `8010` 前端服务。
自扩展接口统一位于 `/api/extension/*`。红外模块默认使用已验证的 GSCU1B TCP
模式:Linux 端在 `0.0.0.0:4444` 监听,GSCU1B 作为 TCP Client 主动接入。
“外设与扩展能力”中的“TCP 设置”可修改监听 IP、端口并立即重启监听,配置持久化到
`artifacts/tmp/gscu_gateway.json`;同一弹窗可执行查询、学习、取消、发射、读取、
写入、软重启、WiFi 配网锁、擦除、恢复出厂和原始 JSON 调试动作。模块配置的
服务器地址必须填写 Linux 的实际局域网 IP,不能填写 `0.0.0.0`。
TCP 发射、取消、写入和设置类动作在操作系统确认写入连接后立即返回“已下发”,
不会因设备不回 ACK 而阻塞前端。固件 1.1.5 的学习动作同样不主动返回 ACK,因此
前端在学习命令下发后自动读取目标槽位,以实际 `ir_data` 回读确认学习完成;设备查询
和读取槽位仍等待带 `messageId` 的实际回执。
学习时可为每个 `1–248` 红外槽位填写信号名称;槽位与名称持久化在本机
`artifacts/server/gscu_ir_catalog.sqlite3` 数据库中,并在 TCP 动作调试区按槽位号
升序显示为小卡片。浏览器中已有的旧名称会自动迁移到数据库。单卡片删除操作只删除
本地目录记录,不会擦除设备红外码;已验证的设备协议仅支持全部擦除。全部擦除会先
弹出不可恢复确认,设备命令提交成功后才同步清空本地槽位数据库。
如需回退 MQTT,可设置 `GSCU_TRANSPORT=mqtt`,通过 `GSCU_MQTT_*`、
`GSCU_COMMAND_TOPIC` 和 `GSCU_RESPONSE_TOPIC` 指定 Broker 与话题;通过
`GSCU_TRANSPORT=simulation` 使用演示学习器。MQTT 依赖已包含在核心环境中;
NFC 串口依赖通过 `ir` 可选依赖安装。自扩展不再需要独立的 `8010` 前端服务。
## NFC 玩具识别与红外学习
统一页面的“外设与扩展能力”区域提供“感应玩具 NFC”入口。点击后将玩具上的
NFC 卡片靠近 PCR532/PN532(CH340/CH341 串口)读卡器:新卡可建立或绑定
玩具档案,并立即进入现有 GSCU1B 红外学习流程;已绑定卡片会自动识别玩具,
展示全部已学习/待学习动作,并可直接发射已验证动作。
安装串口支持并启动:
```bash
python -m pip install -e ".[ir]"
make dev
```
服务会优先复用本机 `ir-toy-learning` 的 MQTT NFC 桥(默认
`127.0.0.1:1883`),避免两个进程同时抢占同一个 `COM` 口;桥不可用时再自动选择
唯一的 CH340/CH341、PN532 或 PCR532 串口。可通过 `NFC_PORT=COM5` 指定串口,
或用 `NFC_MQTT_HOST`、`NFC_MQTT_PORT` 调整桥地址;`NFC_MQTT_DISABLED=true`
强制只使用串口,`NFC_DISABLED=true` 可完全禁用读卡线程。
首次加载时页面会从 `http://localhost:3001/n7web-profile-bridge.html` 安全迁移旧
`ir-toy-learning` 浏览器档案,保留工牌 UID、玩具编号、动作名称和 GSCU1B 槽位。
不同浏览器运行时,旧工作台会把不含红外原始波形的档案元数据同步到本机 MQTT
保留主题 `toy/ir/profiles`,当前服务同样会自动导入。迁移按档案与槽位幂等合并,
不会覆盖当前工程中已经建立的能力。NFC UID 与玩具档案的绑定保存在当前浏览器,
红外波形仍保存在 GSCU1B 的 1–248 号物理槽位。
TCP 模式下本服务是服务器,GSCU1B 是客户端:设备配网页中的“TCP服务器”
必须填写运行本服务的主机地址,“TCP端口”必须与 `GSCU_TCP_PORT` 一致。
@@ -60,6 +185,19 @@ TCP 模式下本服务是服务器,GSCU1B 是客户端:设备配网页中的
- `POST /api/extension/display-sync`:车机刚识别 USB 外设时上报 `device` 与 `peripherals` 能力目录,无需等待首次执行。
- `POST /api/extension/assistant/plan` 和 `/api/extension/vehicle/execute`:联动规划与车端执行结果同样写入同步快照。
右上角“能耗监测”入口以独立固定页面展示 7 个当前可控车端设备的实时功率、
本次监测累计能耗、总功率趋势和分设备趋势。能耗链路优先接收
`cognitive-agent:power-telemetry` 事件,也兼容调用
`window.updateDevicePowerTelemetry(payload)` 的现有集成方式;载荷可使用
`devices.<device>.power_w` 或 `total_power_w`,并可附带 `energy_wh`、
`trip_energy_kwh` 或 `today_energy_kwh`。累计能耗独立保留,瞬时功率超过 15 秒
未更新时只会回退到状态估算,不会让已上报的车端累计值消失。每条遥测应显式提供
`source: "vehicle_power_sensor"`(实测)或 `source: "estimated"`(估算)。
当前 CAN 状态协议没有独立电流/
功率电表信号,因此未注入新鲜功率数据的设备会显示带 `≈` 和“状态估算”标签的
确定性实时估算,绝不标记为车端实测。真实功率通道接入后页面会逐设备自动切换为
“实测”,无需更换页面或曲线实现。
公网入口由后端统一认证,前端不保存密码或 API Key。未认证访问自动进入
`/login`,主控制台提供退出按钮。固定入口是
`https://511.kendeji.fun`,应用容器只监听服务器回环地址,再由 Caddy 提供
+2680 -631
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+897
View File
@@ -0,0 +1,897 @@
/* Independent whole-vehicle energy monitor -------------------------------- */
.console-v2 .energy-monitor-layer {
position: fixed;
z-index: 5000;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
background: color-mix(in oklch, var(--background) 58%, transparent);
backdrop-filter: blur(6px) saturate(.9);
-webkit-backdrop-filter: blur(6px) saturate(.9);
overscroll-behavior: contain;
}
.console-v2 .energy-monitor-layer[hidden] {
display: none !important;
}
.console-v2 .energy-monitor-page {
width: min(1480px, 100%);
height: min(900px, 100%);
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
color: var(--foreground);
background:
radial-gradient(circle at 7% 0%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 30%),
color-mix(in oklch, var(--card) 97%, var(--background));
border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
border-radius: 22px;
box-shadow:
0 28px 70px color-mix(in oklch, var(--foreground) 22%, transparent),
0 4px 18px color-mix(in oklch, var(--primary) 10%, transparent),
inset 0 1px 0 color-mix(in oklch, var(--primary-foreground) 85%, transparent);
font-variant-numeric: tabular-nums;
animation: energy-monitor-enter 180ms ease-out both;
}
.console-v2 .energy-monitor-header {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 14px 18px 13px 20px;
border-bottom: 1px solid color-mix(in oklch, var(--primary) 10%, var(--border));
background: color-mix(in oklch, var(--card) 85%, transparent);
}
.console-v2 .energy-monitor-heading,
.console-v2 .energy-monitor-live-state,
.console-v2 .energy-monitor-heading > div,
.console-v2 .energy-monitor-heading > span,
.console-v2 .energy-monitor-live-label,
.console-v2 .energy-trend-card > header,
.console-v2 .energy-devices-card > header,
.console-v2 .energy-trend-card > footer,
.console-v2 .energy-device-identity small {
display: flex;
align-items: center;
}
.console-v2 .energy-monitor-heading {
min-width: 0;
gap: 12px;
}
.console-v2 .energy-monitor-heading > span {
width: 46px;
height: 46px;
flex: 0 0 46px;
justify-content: center;
color: var(--primary);
background: color-mix(in oklch, var(--primary) 10%, var(--card));
border: 1px solid color-mix(in oklch, var(--primary) 22%, var(--border));
border-radius: 15px;
box-shadow: inset 0 1px 0 color-mix(in oklch, var(--primary-foreground) 70%, transparent);
}
.console-v2 .energy-monitor-heading > span svg {
width: 24px;
height: 24px;
stroke-width: 1.8;
}
.console-v2 .energy-monitor-heading > div {
min-width: 0;
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
.console-v2 .energy-monitor-heading h2,
.console-v2 .energy-trend-card h3,
.console-v2 .energy-devices-card h3 {
margin: 0;
color: var(--heading-foreground, var(--foreground));
letter-spacing: -.025em;
}
.console-v2 .energy-monitor-heading h2 {
font-size: 20px;
line-height: 1.2;
}
.console-v2 .energy-monitor-heading p,
.console-v2 .energy-trend-card header p,
.console-v2 .energy-devices-card header p {
margin: 0;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.45;
}
.console-v2 .energy-monitor-live-state {
flex: 0 0 auto;
gap: 10px;
color: var(--muted-foreground);
font-size: 12px;
}
.console-v2 .energy-monitor-live-label {
gap: 6px;
color: var(--primary);
font-weight: 700;
}
.console-v2 .energy-monitor-live-label > i,
.console-v2 .energy-trend-card > footer span > i {
width: 7px;
height: 7px;
display: block;
background: var(--primary);
border-radius: 50%;
box-shadow: 0 0 0 4px color-mix(in oklch, var(--primary) 12%, transparent);
}
.console-v2 .energy-monitor-source {
padding: 5px 9px;
color: var(--primary);
background: color-mix(in oklch, var(--primary) 9%, var(--card));
border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
border-radius: 999px;
font-weight: 700;
}
.console-v2 .energy-monitor-source[data-source="simulated"],
.console-v2 .energy-monitor-source[data-source="simulated_estimated"],
.console-v2 .energy-monitor-source[data-source="measured_simulated"],
.console-v2 .energy-monitor-source[data-source="measured_simulated_estimated"] {
color: color-mix(in oklch, var(--primary) 72%, #7c3aed);
}
.console-v2 #energyMonitorClose {
width: 38px;
height: 38px;
display: grid;
flex: 0 0 38px;
place-items: center;
padding: 0;
color: var(--foreground);
background: color-mix(in oklch, var(--card) 88%, var(--secondary));
border: 1px solid color-mix(in oklch, var(--primary) 13%, var(--border));
border-radius: 12px;
box-shadow: 0 4px 12px color-mix(in oklch, var(--foreground) 7%, transparent);
cursor: pointer;
transition: color 150ms ease, border-color 150ms ease, background 150ms ease, transform 150ms ease;
}
.console-v2 #energyMonitorClose:hover,
.console-v2 #energyMonitorClose:focus-visible {
color: var(--primary);
background: var(--accent);
border-color: color-mix(in oklch, var(--primary) 28%, var(--border));
outline: 3px solid color-mix(in oklch, var(--ring) 16%, transparent);
outline-offset: 1px;
transform: translateY(-1px);
}
.console-v2 #energyMonitorClose svg {
width: 18px;
height: 18px;
}
.console-v2 .energy-monitor-content {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
padding: 14px 16px 16px;
}
.console-v2 .energy-monitor-kpis {
display: grid;
grid-template-columns: 1.18fr 1fr 1fr 1fr;
gap: 10px;
}
.console-v2 .energy-kpi {
min-width: 0;
min-height: 94px;
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
overflow: hidden;
padding: 13px 15px;
background: color-mix(in oklch, var(--card) 94%, var(--secondary));
border: 1px solid color-mix(in oklch, var(--primary) 11%, var(--border));
border-radius: 15px;
box-shadow: 0 6px 16px color-mix(in oklch, var(--foreground) 5%, transparent);
}
.console-v2 .energy-kpi-primary {
background:
linear-gradient(135deg, color-mix(in oklch, var(--primary) 10%, var(--card)), color-mix(in oklch, var(--primary) 3%, var(--card)));
border-color: color-mix(in oklch, var(--primary) 42%, var(--border));
box-shadow: 0 8px 20px color-mix(in oklch, var(--primary) 10%, transparent);
}
.console-v2 .energy-kpi > small {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 650;
}
.console-v2 .energy-kpi > strong {
min-width: 0;
color: var(--heading-foreground, var(--foreground));
font-size: clamp(19px, 1.9vw, 28px);
line-height: 1.1;
white-space: nowrap;
}
.console-v2 .energy-kpi-primary > strong {
display: flex;
align-items: baseline;
gap: 6px;
color: var(--primary);
font-size: clamp(27px, 2.6vw, 38px);
}
.console-v2 .energy-kpi-primary > strong span {
color: var(--foreground);
font-size: 14px;
font-weight: 700;
}
.console-v2 .energy-kpi > em {
max-width: 100%;
overflow: hidden;
color: var(--muted-foreground);
font-size: 10px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.console-v2 .energy-kpi-primary > svg {
width: 42px;
height: 42px;
position: absolute;
right: 10px;
top: 10px;
color: color-mix(in oklch, var(--primary) 20%, transparent);
stroke-width: 1.4;
}
.console-v2 .energy-monitor-dashboard {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1.58fr) minmax(330px, .92fr);
gap: 12px;
}
.console-v2 .energy-trend-card,
.console-v2 .energy-devices-card {
min-width: 0;
min-height: 0;
overflow: hidden;
background: color-mix(in oklch, var(--card) 96%, var(--secondary));
border: 1px solid color-mix(in oklch, var(--primary) 11%, var(--border));
border-radius: 16px;
box-shadow: 0 7px 18px color-mix(in oklch, var(--foreground) 5%, transparent);
}
.console-v2 .energy-trend-card {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
padding: 15px 16px 12px;
}
.console-v2 .energy-trend-card > header,
.console-v2 .energy-devices-card > header {
justify-content: space-between;
gap: 12px;
}
.console-v2 .energy-trend-card h3,
.console-v2 .energy-devices-card h3 {
font-size: 15px;
}
.console-v2 .energy-trend-card > header > strong {
color: var(--primary);
font-size: 16px;
white-space: nowrap;
}
.console-v2 .energy-trend-tabs {
display: flex;
gap: 7px;
padding: 11px 0 4px;
}
.console-v2 .energy-trend-tabs button {
min-height: 30px;
padding: 5px 13px;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--card) 88%, var(--secondary));
border: 1px solid var(--border);
border-radius: 9px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: color 150ms ease, background 150ms ease, border-color 150ms ease;
}
.console-v2 .energy-trend-tabs button:hover,
.console-v2 .energy-trend-tabs button:focus-visible {
color: var(--primary);
border-color: color-mix(in oklch, var(--primary) 28%, var(--border));
outline: 2px solid color-mix(in oklch, var(--ring) 14%, transparent);
outline-offset: 1px;
}
.console-v2 .energy-trend-tabs button.active {
color: var(--primary-foreground);
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 5px 13px color-mix(in oklch, var(--primary) 20%, transparent);
}
.console-v2 .energy-chart-wrap {
min-height: 210px;
position: relative;
overflow: hidden;
}
.console-v2 .energy-monitor-chart {
width: 100%;
height: 100%;
min-height: 210px;
display: block;
color: var(--primary);
overflow: visible;
}
.console-v2 .energy-chart-grid line {
stroke: color-mix(in oklch, var(--primary) 10%, var(--border));
stroke-width: 1;
stroke-dasharray: 3 5;
}
.console-v2 .energy-chart-area {
fill: url(#energyMonitorAreaGradient);
stroke: none;
}
.console-v2 .energy-chart-line {
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
filter: drop-shadow(0 3px 5px color-mix(in oklch, var(--primary) 18%, transparent));
transition: points 260ms linear;
}
.console-v2 .energy-chart-y {
position: absolute;
z-index: 2;
left: 0;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--card) 82%, transparent);
font-size: 9px;
line-height: 1;
pointer-events: none;
}
.console-v2 .energy-chart-y-top { top: 8px; }
.console-v2 .energy-chart-y-mid { top: 49%; }
.console-v2 .energy-chart-y-bottom { bottom: 26px; }
.console-v2 .energy-chart-x {
position: absolute;
right: 0;
bottom: 3px;
left: 42px;
display: flex;
justify-content: space-between;
color: var(--muted-foreground);
font-size: 9px;
}
.console-v2 .energy-trend-card > footer {
justify-content: space-between;
gap: 10px;
padding-top: 5px;
color: var(--muted-foreground);
border-top: 1px solid color-mix(in oklch, var(--primary) 8%, var(--border));
font-size: 10px;
}
.console-v2 .energy-trend-card > footer span {
display: flex;
align-items: center;
gap: 8px;
color: var(--foreground);
font-weight: 650;
}
.console-v2 .energy-trend-card > footer p {
margin: 0;
}
.console-v2 .energy-devices-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
padding: 15px 12px 10px;
}
.console-v2 .energy-devices-card > header {
padding: 0 3px 10px;
}
.console-v2 .energy-devices-card > header > span {
padding: 5px 8px;
color: var(--primary);
background: color-mix(in oklch, var(--primary) 8%, var(--card));
border-radius: 999px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.console-v2 .energy-device-list {
min-height: 0;
display: grid;
align-content: start;
gap: 6px;
overflow: auto;
padding: 1px 3px 4px;
scrollbar-color: color-mix(in oklch, var(--primary) 24%, var(--border)) transparent;
scrollbar-width: thin;
}
.console-v2 .energy-device-row {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: 34px minmax(82px, 1fr) auto 72px 34px;
align-items: center;
gap: 8px;
padding: 8px 7px;
color: var(--foreground);
text-align: left;
background: color-mix(in oklch, var(--card) 88%, var(--secondary));
border: 1px solid transparent;
border-radius: 11px;
cursor: pointer;
transition: color 150ms ease, background 150ms ease, border-color 150ms ease, transform 150ms ease;
}
.console-v2 .energy-device-row:hover,
.console-v2 .energy-device-row:focus-visible,
.console-v2 .energy-device-row.active {
color: var(--foreground);
background: color-mix(in oklch, var(--primary) 7%, var(--card));
border-color: color-mix(in oklch, var(--primary) 25%, var(--border));
outline: none;
}
.console-v2 .energy-device-row:focus-visible {
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 14%, transparent);
}
.console-v2 .energy-device-row.active {
box-shadow: inset 3px 0 0 var(--primary);
}
.console-v2 .energy-device-icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
color: var(--primary);
background: color-mix(in oklch, var(--primary) 9%, var(--card));
border: 1px solid color-mix(in oklch, var(--primary) 14%, var(--border));
border-radius: 10px;
}
.console-v2 .energy-device-icon svg {
width: 16px;
height: 16px;
}
.console-v2 .energy-device-identity {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.console-v2 .energy-device-identity strong {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.console-v2 .energy-device-identity small {
min-width: 0;
gap: 4px;
color: var(--muted-foreground);
font-size: 8px;
}
.console-v2 .energy-device-identity small b {
color: var(--primary);
font-weight: 750;
}
.console-v2 .energy-device-identity small b[data-source="estimated"] {
color: var(--muted-foreground);
}
.console-v2 .energy-device-identity small i {
width: 2px;
height: 2px;
flex: 0 0 2px;
background: currentColor;
border-radius: 50%;
}
.console-v2 .energy-device-identity small span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.console-v2 .energy-device-metrics {
min-width: 64px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
white-space: nowrap;
}
.console-v2 .energy-device-metrics strong {
color: var(--heading-foreground, var(--foreground));
font-size: 11px;
}
.console-v2 .energy-device-metrics small {
color: var(--muted-foreground);
font-size: 8px;
}
.console-v2 .energy-device-sparkline {
width: 72px;
height: 24px;
display: block;
overflow: visible;
}
.console-v2 .energy-device-sparkline polyline {
fill: none;
stroke: var(--primary);
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.console-v2 .energy-device-share {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.console-v2 .energy-device-share > b {
color: var(--primary);
font-size: 8px;
}
.console-v2 .energy-device-share > i {
width: 30px;
height: 3px;
display: block;
overflow: hidden;
background: color-mix(in oklch, var(--primary) 10%, var(--border));
border-radius: 999px;
}
.console-v2 .energy-device-share em {
width: var(--energy-share, 0%);
height: 100%;
display: block;
background: var(--primary);
border-radius: inherit;
transition: width 260ms linear;
}
.console-v2 .energy-devices-card > footer {
display: flex;
align-items: flex-start;
gap: 7px;
margin-top: 6px;
padding: 8px 9px;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--primary) 5%, var(--secondary));
border-radius: 9px;
font-size: 9px;
line-height: 1.45;
}
.console-v2 .energy-devices-card > footer svg {
width: 13px;
height: 13px;
flex: 0 0 13px;
color: var(--primary);
}
.console-v2 #v2PanelControlDock > .energy-monitor-toggle {
overflow: visible !important;
}
.console-v2 .energy-monitor-toggle-live {
width: 6px;
height: 6px;
position: absolute;
right: 6px;
top: 6px;
display: block;
background: var(--primary);
border: 1.5px solid var(--card);
border-radius: 50%;
box-shadow: 0 0 0 2px color-mix(in oklch, var(--primary) 10%, transparent);
}
@keyframes energy-monitor-enter {
from { opacity: 0; transform: translateY(8px) scale(.992); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (max-width: 980px) {
.console-v2 .energy-monitor-content {
overflow: auto;
}
.console-v2 .energy-monitor-dashboard {
grid-template-columns: minmax(0, 1fr);
}
.console-v2 .energy-trend-card {
min-height: 390px;
}
.console-v2 .energy-devices-card {
min-height: 450px;
}
.console-v2 .energy-device-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: visible;
}
.console-v2 .energy-device-row {
grid-template-columns: 34px minmax(72px, 1fr) auto 52px;
}
.console-v2 .energy-device-share {
display: none;
}
.console-v2 .energy-device-sparkline {
width: 52px;
}
}
@media (max-width: 760px) {
.console-v2 .energy-monitor-layer {
inset: 0;
padding: 56px 8px 8px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.console-v2 .energy-monitor-page {
width: 100%;
height: 100%;
border-radius: 17px;
}
.console-v2 .energy-monitor-header {
min-height: 66px;
gap: 8px;
padding: 10px 10px 9px 12px;
}
.console-v2 .energy-monitor-heading {
gap: 8px;
}
.console-v2 .energy-monitor-heading > span {
width: 38px;
height: 38px;
flex-basis: 38px;
border-radius: 12px;
}
.console-v2 .energy-monitor-heading > span svg {
width: 20px;
height: 20px;
}
.console-v2 .energy-monitor-heading h2 {
font-size: 16px;
}
.console-v2 .energy-monitor-heading p,
.console-v2 #energyMonitorFreshness {
display: none;
}
.console-v2 .energy-monitor-live-state {
gap: 6px;
}
.console-v2 .energy-monitor-live-label {
font-size: 10px;
}
.console-v2 .energy-monitor-source {
padding: 4px 7px;
font-size: 9px;
}
.console-v2 #energyMonitorClose {
width: 34px;
height: 34px;
flex-basis: 34px;
}
.console-v2 .energy-monitor-content {
gap: 9px;
padding: 10px;
}
.console-v2 .energy-monitor-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.console-v2 .energy-kpi {
min-height: 82px;
padding: 10px 11px;
border-radius: 12px;
}
.console-v2 .energy-kpi > strong {
font-size: 18px;
}
.console-v2 .energy-kpi-primary > strong {
font-size: 26px;
}
.console-v2 .energy-trend-card {
min-height: 340px;
padding: 12px 11px 9px;
}
.console-v2 .energy-chart-wrap,
.console-v2 .energy-monitor-chart {
min-height: 190px;
}
.console-v2 .energy-trend-tabs {
gap: 5px;
overflow-x: auto;
padding-bottom: 7px;
}
.console-v2 .energy-trend-tabs button {
flex: 0 0 auto;
padding-inline: 11px;
}
.console-v2 .energy-devices-card {
min-height: 560px;
}
.console-v2 .energy-device-list {
grid-template-columns: minmax(0, 1fr);
}
.console-v2 .energy-device-row {
grid-template-columns: 34px minmax(70px, 1fr) auto 62px 34px;
}
.console-v2 .energy-device-share {
display: flex;
}
.console-v2 .energy-device-sparkline {
width: 62px;
}
}
@media (max-width: 460px) {
.console-v2 .energy-monitor-heading p,
.console-v2 .energy-monitor-live-label {
display: none;
}
.console-v2 .energy-monitor-source {
max-width: 74px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.console-v2 .energy-monitor-kpis {
gap: 6px;
}
.console-v2 .energy-kpi {
min-height: 78px;
}
.console-v2 .energy-kpi > small {
font-size: 9px;
}
.console-v2 .energy-kpi > em {
font-size: 8px;
}
.console-v2 .energy-device-row {
grid-template-columns: 32px minmax(64px, 1fr) auto 50px;
gap: 6px;
padding-inline: 5px;
}
.console-v2 .energy-device-share {
display: none;
}
.console-v2 .energy-device-sparkline {
width: 50px;
}
.console-v2 .energy-trend-card > footer p {
display: none;
}
}
@media (max-width: 340px) {
.console-v2 .energy-monitor-heading > span {
display: none;
}
.console-v2 .energy-monitor-source {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.console-v2 .energy-monitor-page {
animation: none;
}
.console-v2 .energy-chart-line,
.console-v2 .energy-device-share em,
.console-v2 .energy-device-row,
.console-v2 #energyMonitorClose {
transition: none;
}
}
+908
View File
@@ -0,0 +1,908 @@
(function () {
"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const SAMPLE_INTERVAL_MS = 1000;
const MEASURED_STALE_MS = 15000;
const HISTORY_LIMIT = 60;
const MAX_DEVICE_POWER_W = 100000;
const MAX_REPORTED_ENERGY_WH = 1000000000;
const ENERGY_MONITORED_DEVICES = Object.freeze([
{ id: "passenger", telemetryKeys: ["passenger", "passenger_seat"], name: "副驾座椅", icon: "armchair", phase: 1 },
{ id: "driver", telemetryKeys: ["driver", "driver_seat"], name: "主驾座椅", icon: "armchair", phase: 2 },
{ id: "climate", telemetryKeys: ["climate", "hvac"], name: "空调", icon: "snowflake", phase: 3 },
{ id: "shade", telemetryKeys: ["shade", "roof", "sunshade"], name: "遮阳帘", icon: "panels-top-left", phase: 4 },
{ id: "windows", telemetryKeys: ["windows", "window"], name: "车窗", icon: "gallery-vertical-end", phase: 5 },
{ id: "reading", telemetryKeys: ["reading", "reading_light", "interior_light"], name: "阅读灯", icon: "book-open", phase: 6 },
{ id: "ambient", telemetryKeys: ["ambient", "ambient_light"], name: "氛围灯", icon: "sparkles", phase: 7 },
]);
const SERIES = Object.freeze([
{ id: "total", name: "总功率", devices: ENERGY_MONITORED_DEVICES.map(({ id }) => id) },
{ id: "climate", name: "空调", devices: ["climate"] },
{ id: "seats", name: "座椅", devices: ["passenger", "driver"] },
{ id: "lights", name: "灯光", devices: ["reading", "ambient"] },
]);
const aliasToDevice = new Map();
ENERGY_MONITORED_DEVICES.forEach((device) => {
device.telemetryKeys.forEach((key) => aliasToDevice.set(key, device.id));
});
const monitor = {
mounted: false,
open: false,
activeSeries: "total",
timer: null,
startedAt: Date.now(),
lastSampleAt: null,
frames: [],
latest: null,
measured: new Map(),
reportedEnergy: new Map(),
energyWh: Object.fromEntries(ENERGY_MONITORED_DEVICES.map(({ id }) => [id, 0])),
motionUntil: new Map(),
controlRequests: new Map(),
returnFocus: null,
backgroundInert: [],
};
function finiteNonNegative(value) {
if (value === null || value === undefined || value === "") return null;
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : null;
}
function finitePower(value) {
const number = finiteNonNegative(value);
return number !== null && number <= MAX_DEVICE_POWER_W ? number : null;
}
function finiteReportedEnergy(value) {
const number = finiteNonNegative(value);
return number !== null && number <= MAX_REPORTED_ENERGY_WH ? number : null;
}
function normalizeSource(value) {
return String(value || "").trim().toLowerCase().replace(/[\s-]+/g, "_");
}
function telemetrySource(telemetry, payload) {
if (telemetry?.simulated === true || payload?.simulated === true) return "simulated";
if (telemetry?.estimated === true || payload?.estimated === true) return "estimated";
const declared = normalizeSource(
telemetry?.source
|| telemetry?.data_source
|| payload?.source
|| payload?.data_source,
);
if (/(^|_)(simulated|simulation|demo|mock|synthetic|virtual)(_|$)/.test(declared)) {
return "simulated";
}
if (/(^|_)(estimated|estimate|derived|inferred|calculated|projected|unknown)(_|$)/.test(declared)) {
return "estimated";
}
if (telemetry?.measured === true || payload?.measured === true) return "measured";
if (/(^|_)(measured|meter|sensor|hardware|physical|vehicle|can|mqtt)(_|$)/.test(declared)) {
return "measured";
}
// An unlabelled caller is not enough evidence to claim hardware measurement.
return "estimated";
}
function text(selector, fallback = "") {
return $(selector)?.textContent?.trim() || fallback;
}
function stateFlag(value) {
const normalized = String(value || "").trim();
if (/OFF|关闭|停止|未运行/i.test(normalized)) return false;
if (/ON|ACTIVE|AMBIENT|INTERIOR|开启|运行|L[1-9]|高|中|低/i.test(normalized)) return true;
return null;
}
function numericText(selector, fallback) {
const match = text(selector).match(/-?\d+(?:\.\d+)?/);
const value = match ? Number(match[0]) : NaN;
return Number.isFinite(value) ? value : fallback;
}
function deterministicWave(now, phase, amplitude = 0.04) {
const seconds = now / 1000;
return 1
+ Math.sin(seconds / 2.4 + phase * 1.37) * amplitude
+ Math.sin(seconds / 5.7 + phase * 0.71) * amplitude * 0.45;
}
function controlIsActive(deviceId, now) {
if ((monitor.motionUntil.get(deviceId) || 0) > now) return true;
const card = $(`.v2-synced-device[data-device="${deviceId}"]`);
return /等待车机同步/.test(card?.querySelector("[data-device-control-apply]")?.textContent || "");
}
function estimateDevicePower(device, now) {
let watts = 0;
let activity = "待机";
const moving = controlIsActive(device.id, now);
if (device.id === "climate") {
const power = stateFlag(text("#climatePower"));
const fan = Math.max(0, Math.min(7, numericText("#fanLevel", 3)));
const target = (numericText("#driverTemperature", 24) + numericText("#passengerTemperature", 24)) / 2;
if (power === false) {
watts = 3.2;
activity = "待机";
} else {
watts = 390 + fan * 48 + Math.abs(24 - target) * 32;
activity = power === true ? `${fan} 档运行` : "状态估算";
}
if (moving) {
watts += 95;
activity = "调节执行中";
}
} else if (device.id === "passenger") {
const card = $('.seat-card[data-seat="passenger"]');
const ventilation = stateFlag(card?.dataset.ventilationState);
const heating = stateFlag(card?.dataset.heatingState);
const ventilationLevel = Math.max(1, Math.min(3, Number(card?.dataset.ventilationLevel) || 1));
const heatingLevel = Math.max(1, Math.min(3, Number(card?.dataset.heatingLevel) || 1));
watts = 5.6 + (ventilation ? 7 + ventilationLevel * 7 : 0) + (heating ? 18 + heatingLevel * 16 : 0) + (moving ? 130 : 0);
activity = moving ? "执行器动作" : ventilation || heating ? "舒适功能运行" : "待机";
} else if (device.id === "driver") {
const card = $('.seat-card[data-seat="driver"]');
const ventilation = stateFlag(card?.dataset.ventilationState || text('.seat-card[data-seat="driver"] [data-function="ventilation"] b'));
const heating = stateFlag(card?.dataset.heatingState || text('.seat-card[data-seat="driver"] [data-function="heating"] b'));
const massage = stateFlag(card?.dataset.massageState || text('.seat-card[data-seat="driver"] [data-function="massage"] b'));
const ventilationLevel = Math.max(1, Math.min(3, Number(card?.dataset.ventilationLevel) || 1));
const heatingLevel = Math.max(1, Math.min(3, Number(card?.dataset.heatingLevel) || 1));
watts = 6.4 + (ventilation ? 7 + ventilationLevel * 7 : 0) + (heating ? 18 + heatingLevel * 16 : 0) + (massage ? 31 : 0) + (moving ? 92 : 0);
activity = moving ? "执行器动作" : ventilation || heating || massage ? "舒适功能运行" : "待机";
} else if (device.id === "windows") {
watts = moving ? 118 : 2.8;
activity = moving ? "车窗动作" : "待机";
} else if (device.id === "shade") {
watts = moving ? 86 : 1.9;
activity = moving ? "遮阳帘动作" : "待机";
} else if (device.id === "reading") {
const power = stateFlag(text("#interiorLightPower"));
watts = power === false ? 0.8 : power === true ? 18 : 7.5;
activity = power === true ? "已开启" : power === false ? "已关闭" : "状态估算";
if (moving) {
watts += 6;
activity = "切换执行中";
}
} else if (device.id === "ambient") {
const power = stateFlag(text("#ambientPower"));
const brightness = Math.max(1, Math.min(5, numericText("#ambientBrightness", 3)));
watts = power === false ? 1.1 : power === true ? 8 + brightness * 6.2 : 18;
activity = power === true ? `${brightness} 级亮度` : power === false ? "已关闭" : "状态估算";
if (moving) {
watts += 5;
activity = "调光执行中";
}
}
const amplitude = watts < 10 ? 0.025 : device.id === "climate" ? 0.055 : 0.04;
return {
watts: Math.max(0, watts * deterministicWave(now, device.phase, amplitude)),
source: "estimated",
activity,
updatedAt: now,
};
}
function readingForDevice(device, now) {
const measured = monitor.measured.get(device.id);
const reportedEnergy = monitor.reportedEnergy.get(device.id);
if (measured && now - measured.receivedAt <= MEASURED_STALE_MS) {
const activity = measured.source === "measured"
? "车端功率通道"
: measured.source === "simulated" ? "模拟功率通道" : "外部估算通道";
return {
watts: measured.watts,
source: measured.source,
activity,
updatedAt: measured.updatedAt || measured.receivedAt,
reportedEnergyWh: reportedEnergy?.wattsHours ?? null,
reportedEnergyLabel: reportedEnergy?.label || "",
reportedEnergySource: reportedEnergy?.source || "",
};
}
return {
...estimateDevicePower(device, now),
reportedEnergyWh: reportedEnergy?.wattsHours ?? null,
reportedEnergyLabel: reportedEnergy?.label || "",
reportedEnergySource: reportedEnergy?.source || "",
};
}
function frameSeriesValue(frame, seriesId) {
const exactDevice = ENERGY_MONITORED_DEVICES.find(({ id }) => id === seriesId);
const deviceIds = exactDevice
? [exactDevice.id]
: SERIES.find(({ id }) => id === seriesId)?.devices || SERIES[0].devices;
return deviceIds.reduce((sum, id) => sum + (frame.values[id] || 0), 0);
}
function publicSnapshot() {
if (!monitor.latest) return null;
return {
timestamp: monitor.latest.timestamp,
total_power_w: monitor.latest.total,
total_energy_wh: Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0),
source: monitor.latest.source,
measured_count: monitor.latest.measuredCount,
simulated_count: monitor.latest.simulatedCount,
estimated_count: monitor.latest.estimatedCount,
total_energy_scope: "monitor_power_integral_estimate",
devices: Object.fromEntries(ENERGY_MONITORED_DEVICES.map((device) => {
const reading = monitor.latest.readings[device.id];
return [device.id, {
name: device.name,
power_w: reading.watts,
energy_wh: monitor.energyWh[device.id],
reported_energy_wh: reading.reportedEnergyWh,
reported_energy_label: reading.reportedEnergyLabel,
reported_energy_source: reading.reportedEnergySource,
source: reading.source,
activity: reading.activity,
}];
})),
sample_count: monitor.frames.length,
};
}
function pruneExpiredControlRequests(now) {
monitor.controlRequests.forEach((request, requestId) => {
if (request.until <= now) monitor.controlRequests.delete(requestId);
});
}
function sampleNow({ now = Date.now(), integrate = true, render = true } = {}) {
const timestamp = Number(now);
if (!Number.isFinite(timestamp)) return publicSnapshot();
pruneExpiredControlRequests(timestamp);
const readings = {};
const values = {};
let total = 0;
let measuredCount = 0;
let simulatedCount = 0;
ENERGY_MONITORED_DEVICES.forEach((device) => {
const reading = readingForDevice(device, timestamp);
readings[device.id] = reading;
values[device.id] = reading.watts;
total += finitePower(reading.watts) ?? 0;
if (reading.source === "measured") measuredCount += 1;
if (reading.source === "simulated") simulatedCount += 1;
});
const elapsedMs = monitor.lastSampleAt === null
? 0
: Math.max(0, timestamp - monitor.lastSampleAt);
if (integrate && elapsedMs > 0) {
ENERGY_MONITORED_DEVICES.forEach(({ id }) => {
const previous = monitor.frames.at(-1)?.values?.[id] ?? values[id];
monitor.energyWh[id] += ((previous + values[id]) / 2) * elapsedMs / 3600000;
});
}
monitor.lastSampleAt = timestamp;
monitor.frames.push({ timestamp, values, total });
if (monitor.frames.length > HISTORY_LIMIT) monitor.frames.splice(0, monitor.frames.length - HISTORY_LIMIT);
const estimatedCount = ENERGY_MONITORED_DEVICES.length - measuredCount - simulatedCount;
const source = measuredCount === ENERGY_MONITORED_DEVICES.length
? "measured"
: simulatedCount === ENERGY_MONITORED_DEVICES.length
? "simulated"
: estimatedCount === ENERGY_MONITORED_DEVICES.length
? "estimated"
: measuredCount && simulatedCount && estimatedCount
? "measured_simulated_estimated"
: measuredCount && simulatedCount
? "measured_simulated"
: measuredCount ? "mixed" : "simulated_estimated";
monitor.latest = {
timestamp,
readings,
total,
measuredCount,
simulatedCount,
estimatedCount,
source,
};
if (render && monitor.mounted) renderMonitor();
window.dispatchEvent(new CustomEvent("cognitive-agent:energy-snapshot", { detail: publicSnapshot() }));
return publicSnapshot();
}
function ingestPowerTelemetry(payload, { sample = true } = {}) {
const receivedAt = Date.now();
const updates = payload?.devices && typeof payload.devices === "object"
? Object.entries(payload.devices)
: payload?.device ? [[payload.device, payload]] : [];
updates.forEach(([rawKey, rawTelemetry]) => {
const deviceId = aliasToDevice.get(String(rawKey || "").trim().toLowerCase());
if (!deviceId) return;
const telemetry = rawTelemetry && typeof rawTelemetry === "object"
? rawTelemetry
: { power_w: rawTelemetry };
const directEnergyWh = finiteReportedEnergy(telemetry.energy_wh ?? telemetry.total_energy_wh);
const tripEnergyKwh = finiteNonNegative(telemetry.trip_energy_kwh);
const todayEnergyKwh = finiteNonNegative(telemetry.today_energy_kwh);
const tripEnergyWh = tripEnergyKwh === null ? null : finiteReportedEnergy(tripEnergyKwh * 1000);
const todayEnergyWh = todayEnergyKwh === null ? null : finiteReportedEnergy(todayEnergyKwh * 1000);
const reportedEnergyWh = directEnergyWh
?? tripEnergyWh
?? todayEnergyWh
?? null;
const reportedEnergyLabel = directEnergyWh !== null
? String(telemetry.energy_label || "车端累计")
: tripEnergyWh !== null ? "本次行程"
: todayEnergyWh !== null ? "今日累计"
: "";
const source = telemetrySource(telemetry, payload);
if (reportedEnergyWh !== null) {
monitor.reportedEnergy.set(deviceId, {
wattsHours: reportedEnergyWh,
label: reportedEnergyLabel,
source,
receivedAt,
});
}
const watts = finitePower(
telemetry.power_w ?? telemetry.total_power_w ?? telemetry.current_power_w,
);
if (watts === null) return;
const parsedUpdatedAt = telemetry.updated_at ? new Date(telemetry.updated_at).getTime() : NaN;
monitor.measured.set(deviceId, {
watts,
receivedAt,
updatedAt: Number.isFinite(parsedUpdatedAt) ? parsedUpdatedAt : receivedAt,
source,
});
});
return sample ? sampleNow() : publicSnapshot();
}
function formatPowerParts(watts) {
const value = finiteNonNegative(watts) || 0;
if (value >= 1000) return { value: (value / 1000).toFixed(value >= 10000 ? 1 : 2), unit: "kW" };
return { value: String(Math.round(value)), unit: "W" };
}
function formatPower(watts, estimated = false) {
const parts = formatPowerParts(watts);
return `${estimated ? "≈ " : ""}${parts.value} ${parts.unit}`;
}
function formatEnergy(wh, estimated = false) {
const value = finiteNonNegative(wh) || 0;
const prefix = estimated ? "≈ " : "";
if (value >= 1000) return `${prefix}${(value / 1000).toFixed(3)} kWh`;
return `${prefix}${value < 10 ? value.toFixed(2) : value.toFixed(1)} Wh`;
}
function sourceLabel(source) {
return {
measured: "车端实测",
simulated: "模拟数据",
simulated_estimated: "模拟 + 估算",
measured_simulated: "实测 + 模拟",
measured_simulated_estimated: "实测 + 模拟 + 估算",
mixed: "实测 + 估算",
estimated: "状态估算",
}[source] || "状态估算";
}
function sourceShortLabel(source) {
return source === "measured" ? "实测" : source === "simulated" ? "模拟" : "估算";
}
function totalPowerQualifier(source) {
if (source === "measured") return "实测总功率";
if (source === "simulated") return "模拟总功率";
if (source === "estimated") return "估算总功率";
return "混合来源总功率";
}
function niceMaximum(value) {
const safe = Math.max(1, value);
const magnitude = 10 ** Math.floor(Math.log10(safe));
const normalized = safe / magnitude;
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return nice * magnitude;
}
function chartPoints(values, maxValue, width = 700, height = 238, padding = { left: 42, right: 14, top: 16, bottom: 28 }) {
if (values.length < 2) return [];
const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom;
return values.map((value, index) => ({
x: padding.left + innerWidth * index / (values.length - 1),
y: padding.top + innerHeight * (1 - Math.max(0, value) / maxValue),
}));
}
function polylinePoints(values, maxValue, width, height, padding) {
return chartPoints(values, maxValue, width, height, padding)
.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`)
.join(" ");
}
function renderMainChart() {
const values = monitor.frames.map((frame) => frameSeriesValue(frame, monitor.activeSeries));
const maximum = niceMaximum(Math.max(...values, 1));
const points = chartPoints(values, maximum);
const line = $("#energyMonitorChartLine");
const area = $("#energyMonitorChartArea");
const svg = $("#energyMonitorChart");
const baseline = 210;
line?.setAttribute("points", points.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" "));
area?.setAttribute("d", points.length
? `M ${points[0].x.toFixed(1)} ${baseline} L ${points.map(({ x, y }) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(" L ")} L ${points.at(-1).x.toFixed(1)} ${baseline} Z`
: "");
const selectedDevice = ENERGY_MONITORED_DEVICES.find(({ id }) => id === monitor.activeSeries);
const selectedSeries = SERIES.find(({ id }) => id === monitor.activeSeries);
const label = selectedDevice?.name || selectedSeries?.name || "总功率";
const current = values.at(-1) || 0;
const currentReading = selectedDevice ? monitor.latest?.readings?.[selectedDevice.id] : null;
const estimated = currentReading ? currentReading.source !== "measured" : monitor.latest?.source !== "measured";
$("#energyMonitorTrendName").textContent = label;
$("#energyMonitorTrendCurrent").textContent = formatPower(current, estimated);
$("#energyMonitorChartMax").textContent = formatPower(maximum);
$("#energyMonitorChartMid").textContent = formatPower(maximum / 2);
const spanSeconds = Math.max(1, Math.round(((monitor.frames.at(-1)?.timestamp || 0) - (monitor.frames[0]?.timestamp || 0)) / 1000));
const windowSeconds = monitor.frames.length >= HISTORY_LIMIT ? 60 : Math.min(60, spanSeconds);
$("#energyMonitorTrendWindow").textContent = `最近 ${windowSeconds} 秒`;
$("#energyMonitorChartStart").textContent = `${windowSeconds} 秒前`;
if (svg) svg.setAttribute("aria-label", `${label}最近 ${windowSeconds} 秒功率趋势,当前${formatPower(current, estimated)}`);
$$('[data-energy-series]').forEach((button) => {
const selected = button.dataset.energySeries === monitor.activeSeries;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
});
$$('[data-energy-device]').forEach((button) => {
const selected = button.dataset.energyDevice === monitor.activeSeries;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
});
}
function renderDeviceRows() {
if (!monitor.latest) return;
const total = Math.max(monitor.latest.total, 0.0001);
ENERGY_MONITORED_DEVICES.forEach((device) => {
const row = $(`[data-energy-device="${device.id}"]`);
const reading = monitor.latest.readings[device.id];
if (!row || !reading) return;
const estimated = reading.source !== "measured";
$("[data-energy-device-power]", row).textContent = formatPower(reading.watts, estimated);
const hasReportedEnergy = reading.reportedEnergyWh !== null && reading.reportedEnergyWh !== undefined;
const displayedEnergyWh = hasReportedEnergy ? reading.reportedEnergyWh : monitor.energyWh[device.id];
const reportedEnergyEstimated = !hasReportedEnergy || reading.reportedEnergySource !== "measured";
$("[data-energy-device-energy]", row).textContent = formatEnergy(displayedEnergyWh, reportedEnergyEstimated);
$("[data-energy-device-energy]", row).title = hasReportedEnergy
? `${reading.reportedEnergyLabel || "车端累计"}能耗`
: "本次监测功率积分估算";
$("[data-energy-device-source]", row).textContent = sourceShortLabel(reading.source);
$("[data-energy-device-source]", row).dataset.source = reading.source;
$("[data-energy-device-activity]", row).textContent = reading.activity;
const share = Math.max(0, Math.min(100, reading.watts / total * 100));
$("[data-energy-device-share]", row).textContent = `${share.toFixed(1)}%`;
row.style.setProperty("--energy-share", `${share}%`);
const values = monitor.frames.map((frame) => frame.values[device.id] || 0);
const maximum = Math.max(...values, 1);
$("[data-energy-device-sparkline]", row)?.setAttribute(
"points",
polylinePoints(values, maximum, 92, 28, { left: 1, right: 1, top: 3, bottom: 3 }),
);
const energyScope = hasReportedEnergy ? reading.reportedEnergyLabel || "车端累计" : "本次监测估算";
row.setAttribute("aria-label", `${device.name},当前${formatPower(reading.watts, estimated)},${energyScope}${formatEnergy(displayedEnergyWh, reportedEnergyEstimated)},点击查看趋势`);
});
}
function renderMonitor() {
if (!monitor.latest) return;
const totalParts = formatPowerParts(monitor.latest.total);
const estimated = monitor.latest.source !== "measured";
$("#energyMonitorTotalPower").textContent = totalParts.value;
$("#energyMonitorTotalUnit").textContent = totalParts.unit;
$("#energyMonitorTotalQualifier").textContent = totalPowerQualifier(monitor.latest.source);
$("#energyMonitorAccumulated").textContent = formatEnergy(
Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0),
true,
);
const elapsedHours = Math.max(1 / 3600, (monitor.latest.timestamp - monitor.startedAt) / 3600000);
$("#energyMonitorAverage").textContent = formatPower(
Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0) / elapsedHours,
true,
);
$("#energyMonitorCoverage").textContent = `${ENERGY_MONITORED_DEVICES.length} / ${ENERGY_MONITORED_DEVICES.length}`;
$("#energyMonitorSource").textContent = sourceLabel(monitor.latest.source);
$("#energyMonitorSource").dataset.source = monitor.latest.source;
const sourceBreakdown = [
monitor.latest.measuredCount ? `${monitor.latest.measuredCount} 路实测` : "",
monitor.latest.simulatedCount ? `${monitor.latest.simulatedCount} 路模拟` : "",
monitor.latest.estimatedCount ? `${monitor.latest.estimatedCount} 路估算` : "",
].filter(Boolean).join(" · ");
$("#energyMonitorCoverageNote").textContent = sourceBreakdown || "等待设备采样";
$("#energyMonitorLiveLabelText").textContent = monitor.latest.measuredCount
? "实时监测"
: monitor.latest.simulatedCount ? "动态模拟" : "状态估算";
$("#energyMonitorFreshness").textContent = `采样更新 ${new Date(monitor.latest.timestamp).toLocaleTimeString("zh-CN", { hour12: false })}`;
$("#energyMonitorPage").dataset.revision = String(Number($("#energyMonitorPage").dataset.revision || 0) + 1);
$("#energyMonitorPage").dataset.sampleCount = String(monitor.frames.length);
renderMainChart();
renderDeviceRows();
}
function deviceRowsMarkup() {
return ENERGY_MONITORED_DEVICES.map((device) => `
<button class="energy-device-row" type="button" data-energy-device="${device.id}" aria-pressed="false">
<span class="energy-device-icon"><i data-lucide="${device.icon}"></i></span>
<span class="energy-device-identity"><strong>${device.name}</strong><small><b data-energy-device-source data-source="estimated">估算</b><i></i><span data-energy-device-activity>等待采样</span></small></span>
<span class="energy-device-metrics"><strong data-energy-device-power>-- W</strong><small data-energy-device-energy>-- Wh</small></span>
<svg class="energy-device-sparkline" viewBox="0 0 92 28" preserveAspectRatio="none" aria-hidden="true"><polyline data-energy-device-sparkline points=""></polyline></svg>
<span class="energy-device-share"><b data-energy-device-share>0%</b><i><em></em></i></span>
</button>`).join("");
}
function createLayer() {
const trigger = document.createElement("button");
trigger.id = "energyMonitorToggle";
trigger.className = "energy-monitor-toggle v2-panel-control-button";
trigger.type = "button";
trigger.setAttribute("aria-controls", "energyMonitorLayer");
trigger.setAttribute("aria-pressed", "false");
trigger.setAttribute("aria-expanded", "false");
trigger.setAttribute("aria-label", "打开整车能耗监测");
trigger.title = "打开整车能耗监测";
trigger.innerHTML = '<i data-lucide="activity"></i><span class="visually-hidden">整车能耗监测</span><b class="energy-monitor-toggle-live" aria-hidden="true"></b>';
const layer = document.createElement("div");
layer.id = "energyMonitorLayer";
layer.className = "energy-monitor-layer";
layer.hidden = true;
layer.inert = true;
layer.setAttribute("role", "dialog");
layer.setAttribute("aria-modal", "true");
layer.setAttribute("aria-labelledby", "energyMonitorTitle");
layer.innerHTML = `
<section class="energy-monitor-page" id="energyMonitorPage" data-revision="0" data-sample-count="0">
<header class="energy-monitor-header">
<div class="energy-monitor-heading">
<span><i data-lucide="gauge"></i></span>
<div><h2 id="energyMonitorTitle">整车能耗监测</h2><p>实时追踪各可控车端设备功率与累计能耗</p></div>
</div>
<div class="energy-monitor-live-state">
<span class="energy-monitor-live-label"><i></i><b id="energyMonitorLiveLabelText">状态估算</b></span>
<span id="energyMonitorFreshness">最后更新 --:--:--</span>
<span class="energy-monitor-source" id="energyMonitorSource" data-source="estimated">状态估算</span>
<button id="energyMonitorClose" type="button" aria-label="关闭整车能耗监测" title="关闭"><i data-lucide="x"></i></button>
</div>
</header>
<div class="energy-monitor-content">
<section class="energy-monitor-kpis" aria-label="整车能耗摘要">
<article class="energy-kpi energy-kpi-primary"><small>实时总功率</small><strong><b id="energyMonitorTotalPower">--</b><span id="energyMonitorTotalUnit">W</span></strong><em id="energyMonitorTotalQualifier">等待采样</em><i data-lucide="zap"></i></article>
<article class="energy-kpi"><small>本次监测累计能耗</small><strong id="energyMonitorAccumulated">-- Wh</strong><em>按实时功率积分</em></article>
<article class="energy-kpi"><small>本次监测平均功率</small><strong id="energyMonitorAverage">-- W</strong><em>自页面加载开始</em></article>
<article class="energy-kpi"><small>监测设备</small><strong id="energyMonitorCoverage">7 / 7</strong><em id="energyMonitorCoverageNote">等待设备采样</em></article>
</section>
<div class="energy-monitor-dashboard">
<section class="energy-trend-card" aria-labelledby="energyTrendTitle">
<header>
<div><h3 id="energyTrendTitle">功率趋势</h3><p><span id="energyMonitorTrendName">总功率</span> · <span id="energyMonitorTrendWindow">最近 24 秒</span></p></div>
<strong id="energyMonitorTrendCurrent">-- W</strong>
</header>
<div class="energy-trend-tabs" role="group" aria-label="选择功率趋势">
${SERIES.map((series) => `<button type="button" data-energy-series="${series.id}" aria-pressed="${String(series.id === "total")}" class="${series.id === "total" ? "active" : ""}">${series.name}</button>`).join("")}
</div>
<div class="energy-chart-wrap">
<span class="energy-chart-y energy-chart-y-top" id="energyMonitorChartMax">-- W</span>
<span class="energy-chart-y energy-chart-y-mid" id="energyMonitorChartMid">-- W</span>
<span class="energy-chart-y energy-chart-y-bottom">0</span>
<svg id="energyMonitorChart" class="energy-monitor-chart" viewBox="0 0 700 238" preserveAspectRatio="none" role="img" aria-label="功率趋势">
<defs><linearGradient id="energyMonitorAreaGradient" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="currentColor" stop-opacity=".22"></stop><stop offset="1" stop-color="currentColor" stop-opacity=".015"></stop></linearGradient></defs>
<g class="energy-chart-grid"><line x1="42" y1="16" x2="686" y2="16"></line><line x1="42" y1="113" x2="686" y2="113"></line><line x1="42" y1="210" x2="686" y2="210"></line></g>
<path id="energyMonitorChartArea" class="energy-chart-area" d=""></path>
<polyline id="energyMonitorChartLine" class="energy-chart-line" points=""></polyline>
</svg>
<div class="energy-chart-x"><span id="energyMonitorChartStart">60 秒前</span><span>现在</span></div>
</div>
<footer><span><i></i>当前选中曲线</span><p>点击右侧设备可查看其独立趋势</p></footer>
</section>
<section class="energy-devices-card" aria-labelledby="energyDevicesTitle">
<header><div><h3 id="energyDevicesTitle">设备实时功率</h3><p>功率 · 累计能耗 · 占比</p></div><span>${ENERGY_MONITORED_DEVICES.length} 个可控设备</span></header>
<div class="energy-device-list" id="energyMonitorDeviceList">${deviceRowsMarkup()}</div>
<footer><i data-lucide="info"></i><span>优先读取车端功率通道;未接入独立电表的设备显示明确标注的状态估算值。</span></footer>
</section>
</div>
</div>
</section>`;
$("#v2PanelControlDock").append(trigger);
document.body.append(layer);
trigger.addEventListener("click", () => setOpen(!monitor.open));
$("#energyMonitorClose", layer).addEventListener("click", () => setOpen(false));
layer.addEventListener("click", (event) => {
if (event.target === layer) setOpen(false);
});
layer.addEventListener("click", (event) => {
const seriesButton = event.target.closest("[data-energy-series]");
const deviceButton = event.target.closest("[data-energy-device]");
if (seriesButton) selectSeries(seriesButton.dataset.energySeries);
if (deviceButton) selectSeries(deviceButton.dataset.energyDevice);
});
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function focusableElements() {
return $$('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', $("#energyMonitorLayer"))
.filter((element) => !element.hidden && element.offsetParent !== null);
}
function setBackgroundInert(inert) {
const layer = $("#energyMonitorLayer");
if (!layer || !document.body) return;
if (inert) {
monitor.backgroundInert = [...document.body.children]
.filter((element) => element !== layer && !["SCRIPT", "STYLE", "LINK"].includes(element.tagName))
.map((element) => ({ element, wasInert: element.hasAttribute("inert") }));
monitor.backgroundInert.forEach(({ element }) => { element.toggleAttribute("inert", true); });
return;
}
monitor.backgroundInert.forEach(({ element, wasInert }) => { element.toggleAttribute("inert", wasInert); });
monitor.backgroundInert = [];
}
function captureModalKeys(event) {
if (!monitor.open) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopImmediatePropagation();
setOpen(false);
return;
}
if (event.key !== "Tab") return;
const focusable = focusableElements();
if (!focusable.length) return;
const first = focusable[0];
const last = focusable.at(-1);
const layer = $("#energyMonitorLayer");
if (!layer?.contains(document.activeElement)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function setOpen(open) {
const next = Boolean(open);
const layer = $("#energyMonitorLayer");
const trigger = $("#energyMonitorToggle");
if (!layer || !trigger || monitor.open === next) return;
monitor.open = next;
trigger.classList.toggle("active", next);
trigger.setAttribute("aria-pressed", String(next));
trigger.setAttribute("aria-expanded", String(next));
trigger.setAttribute("aria-label", next ? "关闭整车能耗监测" : "打开整车能耗监测");
trigger.title = next ? "关闭整车能耗监测" : "打开整车能耗监测";
if (next) {
monitor.returnFocus = document.activeElement;
layer.hidden = false;
layer.inert = false;
layer.dataset.open = "true";
setBackgroundInert(true);
renderMonitor();
requestAnimationFrame(() => $("#energyMonitorClose", layer)?.focus());
} else {
layer.dataset.open = "false";
layer.inert = true;
layer.hidden = true;
setBackgroundInert(false);
const target = monitor.returnFocus instanceof HTMLElement && monitor.returnFocus.isConnected
? monitor.returnFocus
: trigger;
monitor.returnFocus = null;
target.focus();
}
}
function selectSeries(seriesId) {
const valid = SERIES.some(({ id }) => id === seriesId)
|| ENERGY_MONITORED_DEVICES.some(({ id }) => id === seriesId);
if (!valid) return;
monitor.activeSeries = seriesId;
renderMainChart();
}
function deviceIdsForControlDomain(domain, context = "") {
const normalizedDomain = normalizeSource(domain).replace(/^native_/, "");
const normalizedContext = normalizeSource(context).replace(/[^a-z0-9\u4e00-\u9fff]+/g, "_");
if (normalizedDomain === "seat" || /(^|_)seat(_|$)/.test(normalizedContext)) {
if (/(^|_)(driver|front_left|left_front)(_|$)|主驾/.test(normalizedContext)) return ["driver"];
if (/(^|_)(passenger|front_right|right_front)(_|$)|副驾/.test(normalizedContext)) return ["passenger"];
return ["passenger"];
}
if (["window", "windows"].includes(normalizedDomain) || /(^|_)window(s)?(_|$)/.test(normalizedContext)) return ["windows"];
if (["roof", "shade", "sunshade"].includes(normalizedDomain) || /(^|_)(roof|shade|sunshade)(_|$)/.test(normalizedContext)) return ["shade"];
if (["climate", "hvac", "outlet"].includes(normalizedDomain) || /(^|_)(climate|hvac|outlet|ac)(_|$)/.test(normalizedContext)) return ["climate"];
if (["interior_light", "reading", "reading_light"].includes(normalizedDomain) || /(^|_)(interior|reading)_?light(_|$)/.test(normalizedContext)) return ["reading"];
if (["ambient", "ambient_light"].includes(normalizedDomain) || /(^|_)ambient_?light(_|$)/.test(normalizedContext)) return ["ambient"];
return [];
}
function controlActivityDevices(payload = {}) {
const wrappedResult = payload?.result && typeof payload.result === "object";
const eventPayload = wrappedResult ? payload.result : payload;
const status = String(eventPayload?.status || "");
if (payload?.error) return [];
if (wrappedResult && !["succeeded", "partially_succeeded"].includes(status)) return [];
if (!wrappedResult && status && !["processing", "succeeded", "partially_succeeded", "submitted"].includes(status)) return [];
if (eventPayload?.preview_only === true || /_preview$/.test(String(eventPayload?.orchestration_route || ""))) {
return [];
}
const deviceIds = new Set();
const directId = aliasToDevice.get(normalizeSource(eventPayload?.device_id))
|| normalizeSource(eventPayload?.device_id);
if (ENERGY_MONITORED_DEVICES.some(({ id }) => id === directId)) deviceIds.add(directId);
const actions = [
...(Array.isArray(eventPayload?.actions) ? eventPayload.actions : []),
...(Array.isArray(eventPayload?.validated_actions) ? eventPayload.validated_actions : []),
...(Array.isArray(eventPayload?.intent_ir?.spec?.actions) ? eventPayload.intent_ir.spec.actions : []),
];
actions.forEach((action) => {
const capabilityDomain = String(action?.capability || "").split(".")[0];
const context = JSON.stringify({
capability: action?.capability,
target: action?.target,
});
deviceIdsForControlDomain(action?.domain || capabilityDomain, context).forEach((id) => deviceIds.add(id));
});
const functionConfigs = Object.entries(eventPayload?.function_configs || {});
if (eventPayload?.function_config && typeof eventPayload.function_config === "object") {
functionConfigs.push([eventPayload.function_config?.spec?.domain || "function", eventPayload.function_config]);
}
functionConfigs.forEach(([configKey, config]) => {
const domain = config?.spec?.domain || config?.domain || configKey;
const operations = config?.spec?.operations || config?.operations || [];
if (!operations.length) {
deviceIdsForControlDomain(domain, configKey).forEach((id) => deviceIds.add(id));
return;
}
operations.forEach((operation) => {
const context = JSON.stringify({
capability: operation?.capability,
target: operation?.target,
});
deviceIdsForControlDomain(operation?.domain || domain, context).forEach((id) => deviceIds.add(id));
});
});
Object.entries(eventPayload?.command_plans || {}).forEach(([planKey, plan]) => {
const domain = plan?.spec?.domain || plan?.domain || planKey;
const commands = plan?.spec?.commands || plan?.commands || [];
const context = commands.length ? JSON.stringify(commands) : planKey;
deviceIdsForControlDomain(domain, context).forEach((id) => deviceIds.add(id));
});
return [...deviceIds];
}
function markControlActivity(payload) {
const until = Date.now() + 4000;
const requestId = controlRequestId(payload);
const currentDeviceIds = controlActivityDevices(payload);
const existing = requestId ? monitor.controlRequests.get(requestId) : null;
const deviceIds = [...new Set([...(existing?.deviceIds || []), ...currentDeviceIds])];
deviceIds.forEach((deviceId) => {
monitor.motionUntil.set(deviceId, Math.max(monitor.motionUntil.get(deviceId) || 0, until));
});
if (requestId && deviceIds.length) {
monitor.controlRequests.set(requestId, { deviceIds, until });
}
return currentDeviceIds;
}
function controlRequestId(payload = {}) {
const eventPayload = payload?.result && typeof payload.result === "object"
? payload.result
: payload;
return String(eventPayload?.request_id || payload?.request_id || "").trim();
}
function completeControlActivity(payload = {}) {
const eventPayload = payload?.result && typeof payload.result === "object"
? payload.result
: payload;
const requestId = controlRequestId(payload);
const tracked = requestId ? monitor.controlRequests.get(requestId) : null;
const succeeded = !payload?.error
&& ["succeeded", "partially_succeeded"].includes(String(eventPayload?.status || ""));
if (tracked) {
monitor.controlRequests.delete(requestId);
if (!succeeded) {
const now = Date.now();
tracked.deviceIds.forEach((deviceId) => {
if ((monitor.motionUntil.get(deviceId) || 0) > tracked.until) return;
const remainingUntil = [...monitor.controlRequests.values()]
.filter((request) => request.deviceIds.includes(deviceId))
.reduce((maximum, request) => Math.max(maximum, request.until), now);
monitor.motionUntil.set(deviceId, remainingUntil);
});
}
return tracked.deviceIds;
}
return succeeded ? markControlActivity(payload) : [];
}
function seedHistory() {
const now = Date.now();
for (let offset = 23; offset >= 1; offset -= 1) {
sampleNow({ now: now - offset * SAMPLE_INTERVAL_MS, integrate: false, render: false });
}
monitor.lastSampleAt = now - SAMPLE_INTERVAL_MS;
sampleNow({ now, integrate: false, render: false });
monitor.startedAt = now;
}
function mount(attempt = 0) {
if (monitor.mounted) return;
if (!$("#v2PanelControlDock")) {
if (attempt < 20) window.setTimeout(() => mount(attempt + 1), 50);
return;
}
monitor.mounted = true;
createLayer();
seedHistory();
renderMonitor();
monitor.timer = window.setInterval(
() => sampleNow({ render: monitor.open }),
SAMPLE_INTERVAL_MS,
);
document.addEventListener("keydown", captureModalKeys, true);
window.addEventListener("cognitive-agent:power-telemetry", (event) => ingestPowerTelemetry(event.detail || {}, { sample: false }));
const existingUpdater = window.updateDevicePowerTelemetry;
if (typeof existingUpdater === "function" && !existingUpdater.energyMonitorLinked) {
const linkedUpdater = (payload) => {
const result = existingUpdater(payload);
ingestPowerTelemetry(payload, { sample: false });
return result;
};
linkedUpdater.energyMonitorLinked = true;
window.updateDevicePowerTelemetry = linkedUpdater;
}
window.addEventListener("cognitive-agent:vehicle-can-control-request", (event) => markControlActivity(event.detail || {}));
window.addEventListener("cognitive-agent:validated-can-commands", (event) => markControlActivity(event.detail || {}));
window.addEventListener("cognitive-agent:vehicle-control-activity", (event) => markControlActivity(event.detail || {}));
window.addEventListener("cognitive-agent:request-complete", (event) => completeControlActivity(event.detail || {}));
}
window.CognitiveAgentEnergyMonitor = {
close: () => setOpen(false),
getDevices: () => ENERGY_MONITORED_DEVICES.map(({ id, name, telemetryKeys }) => ({ id, name, telemetryKeys: [...telemetryKeys] })),
getSnapshot: publicSnapshot,
ingestTelemetry: ingestPowerTelemetry,
isOpen: () => monitor.open,
completeControlActivity,
notifyControlActivity: markControlActivity,
open: () => setOpen(true),
sampleNow,
selectSeries,
};
document.addEventListener("DOMContentLoaded", () => mount());
}());
@@ -0,0 +1,75 @@
.console-v2 .firmware-workbench-layer {
position: fixed;
z-index: 5000;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
background: color-mix(in oklch, var(--background) 58%, transparent);
backdrop-filter: blur(6px) saturate(.9);
-webkit-backdrop-filter: blur(6px) saturate(.9);
overscroll-behavior: contain;
}
.console-v2 .firmware-workbench-layer[hidden] { display: none !important; }
.console-v2 .firmware-workbench-page {
width: min(1480px, 100%);
height: min(900px, 100%);
min-height: 0;
display: grid;
grid-template-rows: 76px minmax(0, 1fr);
overflow: hidden;
color: var(--foreground);
background: color-mix(in oklch, var(--card) 97%, var(--background));
border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
border-radius: 22px;
box-shadow: 0 28px 70px color-mix(in oklch, var(--foreground) 22%, transparent), 0 4px 18px color-mix(in oklch, var(--primary) 10%, transparent), inset 0 1px 0 color-mix(in oklch, var(--primary-foreground) 85%, transparent);
animation: firmware-workbench-enter 180ms ease-out both;
}
.console-v2 .firmware-workbench-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 14px 18px 13px 20px;
border-bottom: 1px solid color-mix(in oklch, var(--primary) 10%, var(--border));
background: color-mix(in oklch, var(--card) 85%, transparent);
}
.console-v2 .firmware-workbench-heading { min-width: 0; display: flex; align-items: center; gap: 12px; }
.console-v2 .firmware-workbench-heading > span {
width: 46px;
height: 46px;
flex: 0 0 46px;
display: grid;
place-items: center;
color: var(--primary);
background: color-mix(in oklch, var(--primary) 10%, var(--card));
border: 1px solid color-mix(in oklch, var(--primary) 22%, var(--border));
border-radius: 15px;
}
.console-v2 .firmware-workbench-heading svg { width: 24px; height: 24px; stroke-width: 1.8; }
.console-v2 .firmware-workbench-heading h2 { margin: 0; color: var(--heading-foreground, var(--foreground)); font-size: 20px; line-height: 1.2; letter-spacing: -.025em; }
.console-v2 #firmwareWorkbenchClose {
width: 38px;
height: 38px;
display: grid;
flex: 0 0 38px;
place-items: center;
padding: 0;
color: var(--foreground);
background: color-mix(in oklch, var(--card) 88%, var(--secondary));
border: 1px solid color-mix(in oklch, var(--primary) 13%, var(--border));
border-radius: 12px;
cursor: pointer;
}
.console-v2 #firmwareWorkbenchClose:hover,
.console-v2 #firmwareWorkbenchClose:focus-visible { color: var(--primary); background: var(--accent); outline: 3px solid color-mix(in oklch, var(--ring) 16%, transparent); }
.console-v2 #firmwareWorkbenchClose svg { width: 18px; height: 18px; }
.console-v2 .firmware-workbench-frame { width: 100%; height: 100%; display: block; border: 0; background: transparent; }
@keyframes firmware-workbench-enter { from { opacity: 0; transform: translateY(8px) scale(.992); } to { opacity: 1; transform: none; } }
@media (max-width: 760px) {
.console-v2 .firmware-workbench-layer { padding: 54px 8px 8px; }
.console-v2 .firmware-workbench-page { height: 100%; border-radius: 17px; grid-template-rows: 64px minmax(0, 1fr); }
.console-v2 .firmware-workbench-header { padding: 9px 11px; }
.console-v2 .firmware-workbench-heading > span { width: 40px; height: 40px; flex-basis: 40px; border-radius: 12px; }
.console-v2 .firmware-workbench-heading h2 { font-size: 17px; }
}
@@ -0,0 +1,127 @@
(() => {
"use strict";
const state = { open: false, returnFocus: null, backgroundInert: [] };
function currentWorkbenchTheme() {
return document.documentElement.dataset.glassTheme === "light" ? "light" : "dark";
}
function syncWorkbenchTheme(frame) {
frame?.contentWindow?.postMessage({
type: "cognitive-agent:theme-change",
theme: currentWorkbenchTheme(),
}, window.location.origin);
}
function setBackgroundInert(inert) {
const layer = document.querySelector("#firmwareWorkbenchLayer");
if (!layer) return;
if (inert) {
state.backgroundInert = [...document.body.children]
.filter((element) => element !== layer && !["SCRIPT", "STYLE", "LINK"].includes(element.tagName))
.map((element) => ({ element, wasInert: element.hasAttribute("inert") }));
state.backgroundInert.forEach(({ element }) => element.toggleAttribute("inert", true));
return;
}
state.backgroundInert.forEach(({ element, wasInert }) => element.toggleAttribute("inert", wasInert));
state.backgroundInert = [];
}
function setOpen(open) {
const next = Boolean(open);
const layer = document.querySelector("#firmwareWorkbenchLayer");
const trigger = document.querySelector("#firmwareWorkbenchToggle");
if (!layer || !trigger || state.open === next) return;
state.open = next;
trigger.classList.toggle("active", next);
trigger.setAttribute("aria-pressed", String(next));
trigger.setAttribute("aria-expanded", String(next));
trigger.setAttribute("aria-label", next ? "关闭座椅固件开发工作台" : "打开座椅固件开发工作台");
trigger.title = next ? "关闭座椅固件开发工作台" : "打开座椅固件开发工作台";
if (next) {
state.returnFocus = document.activeElement;
layer.hidden = false;
layer.inert = false;
layer.dataset.open = "true";
setBackgroundInert(true);
requestAnimationFrame(() => document.querySelector("#firmwareWorkbenchClose")?.focus());
} else {
layer.dataset.open = "false";
layer.inert = true;
layer.hidden = true;
setBackgroundInert(false);
const target = state.returnFocus instanceof HTMLElement && state.returnFocus.isConnected
? state.returnFocus
: trigger;
state.returnFocus = null;
target.focus();
}
}
function mount() {
const dock = document.querySelector("#v2PanelControlDock");
if (!dock || document.querySelector("#firmwareWorkbenchToggle")) return false;
const trigger = document.createElement("button");
trigger.id = "firmwareWorkbenchToggle";
trigger.className = "firmware-workbench-toggle v2-panel-control-button";
trigger.type = "button";
trigger.setAttribute("aria-controls", "firmwareWorkbenchLayer");
trigger.setAttribute("aria-pressed", "false");
trigger.setAttribute("aria-expanded", "false");
trigger.setAttribute("aria-label", "打开座椅固件开发工作台");
trigger.title = "打开座椅固件开发工作台";
trigger.innerHTML = '<i data-lucide="blocks"></i><span class="visually-hidden">座椅固件开发工作台</span>';
const layer = document.createElement("div");
layer.id = "firmwareWorkbenchLayer";
layer.className = "firmware-workbench-layer";
layer.hidden = true;
layer.inert = true;
layer.setAttribute("role", "dialog");
layer.setAttribute("aria-modal", "true");
layer.setAttribute("aria-labelledby", "firmwareWorkbenchTitle");
layer.innerHTML = `
<section class="firmware-workbench-page">
<header class="firmware-workbench-header">
<div class="firmware-workbench-heading">
<span><i data-lucide="blocks"></i></span>
<h2 id="firmwareWorkbenchTitle">座椅固件开发工作台</h2>
</div>
<button id="firmwareWorkbenchClose" type="button" aria-label="关闭座椅固件开发工作台" title="关闭"><i data-lucide="x"></i></button>
</header>
<iframe class="firmware-workbench-frame" title="座椅固件开发工作台"></iframe>
</section>`;
dock.append(trigger);
document.body.append(layer);
const frame = document.querySelector(".firmware-workbench-frame", layer);
frame.src = `/firmware-workbench.html?embedded=1&theme=${currentWorkbenchTheme()}`;
frame.addEventListener("load", () => syncWorkbenchTheme(frame));
window.addEventListener("cognitive-agent:theme-change", () => syncWorkbenchTheme(frame));
new MutationObserver(() => syncWorkbenchTheme(frame)).observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-glass-theme"],
});
trigger.addEventListener("click", () => setOpen(!state.open));
document.querySelector("#firmwareWorkbenchClose", layer).addEventListener("click", () => setOpen(false));
layer.addEventListener("click", (event) => { if (event.target === layer) setOpen(false); });
document.addEventListener("keydown", (event) => {
if (state.open && event.key === "Escape") {
event.preventDefault();
setOpen(false);
}
});
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
return true;
}
if (!mount()) {
const observer = new MutationObserver(() => {
if (mount()) observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
window.setTimeout(() => observer.disconnect(), 15000);
}
})();
@@ -0,0 +1,65 @@
(() => {
"use strict";
const STORAGE_KEY = "cognitive-agent.glass-theme";
function normalizeTheme(value) {
return value === "light" ? "light" : value === "dark" ? "dark" : "";
}
function themeFromCookie() {
try {
return normalizeTheme(document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${STORAGE_KEY}=`))
?.slice(STORAGE_KEY.length + 1));
} catch (_error) {
return "";
}
}
function resolveInitialTheme() {
const requested = normalizeTheme(new URLSearchParams(window.location.search).get("theme"));
if (requested) return requested;
try {
const parentTheme = normalizeTheme(window.parent?.document?.documentElement?.dataset?.glassTheme);
if (parentTheme) return parentTheme;
} catch (_error) {
// Standalone and cross-origin rendering continue with local preferences.
}
try {
const stored = normalizeTheme(localStorage.getItem(STORAGE_KEY));
if (stored) return stored;
} catch (_error) {
// Storage may be unavailable in hardened browser contexts.
}
return themeFromCookie() || "dark";
}
function applyTheme(theme) {
const normalized = normalizeTheme(theme) || "dark";
document.documentElement.dataset.glassTheme = normalized;
document.documentElement.style.colorScheme = normalized;
document.querySelector("#firmwareWorkbenchThemeColor")
?.setAttribute("content", normalized === "dark" ? "#0f1526" : "#f3f6fb");
return normalized;
}
applyTheme(resolveInitialTheme());
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY) applyTheme(event.newValue);
});
window.addEventListener("message", (event) => {
if (event.origin !== window.location.origin) return;
if (window.parent !== window && event.source !== window.parent) return;
if (event.data?.type !== "cognitive-agent:theme-change") return;
applyTheme(event.data.theme);
});
window.CognitiveAgentFirmwareWorkbenchTheme = Object.freeze({
apply: applyTheme,
current: () => document.documentElement.dataset.glassTheme,
});
})();
+626
View File
@@ -0,0 +1,626 @@
:root {
color-scheme: light;
font-family: "Microsoft YaHei UI", "PingFang SC", Inter, system-ui, sans-serif;
--page: #eef3f9;
--surface: #ffffff;
--surface-soft: #f7f9fc;
--line: #dce4ee;
--line-strong: #cbd7e5;
--text: #172033;
--muted: #68758b;
--primary: #2869e8;
--primary-soft: #eaf1ff;
--success: #16845b;
--success-soft: #e7f7f0;
--warning: #ba7314;
--danger: #c23f4a;
--shadow: 0 22px 60px rgba(43, 67, 101, .14), 0 4px 14px rgba(43, 67, 101, .08);
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body {
display: grid;
place-items: center;
padding: 16px;
color: var(--text);
font-size: 14px;
line-height: 1.45;
background:
radial-gradient(circle at 15% 8%, rgba(74, 130, 235, .11), transparent 31%),
linear-gradient(145deg, #f7f9fc 0%, var(--page) 100%);
}
button, textarea, select { font: inherit; }
button, a, select { -webkit-tap-highlight-color: transparent; }
button, select { cursor: pointer; }
.shell {
width: min(1280px, 100%);
height: min(780px, calc(100dvh - 32px));
min-height: 620px;
overflow: hidden;
background: #f7f9fc;
border: 1px solid rgba(205, 216, 230, .9);
border-radius: 22px;
box-shadow: var(--shadow);
}
.topbar {
height: 68px;
padding: 0 22px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
background: rgba(255, 255, 255, .94);
border-bottom: 1px solid var(--line);
}
.topbar-main, .topbar-status, .card-heading, .button-row, .log-actions,
.server-target, .command-footer, .capability-brief { display: flex; align-items: center; }
.topbar-main { gap: 11px; min-width: 0; }
.topbar-status { gap: 9px; flex-wrap: wrap; justify-content: flex-end; }
.back-link, .product-mark, .icon-button, .result-icon {
display: inline-grid;
place-items: center;
}
.back-link, .icon-button {
width: 34px;
height: 34px;
border: 1px solid var(--line);
border-radius: 10px;
color: #4d5b71;
background: var(--surface);
text-decoration: none;
}
.back-link:hover, .icon-button:hover { color: var(--primary); border-color: #a9c1ef; background: var(--primary-soft); }
.back-link svg, .icon-button svg { width: 17px; height: 17px; }
.product-mark { width: 36px; height: 36px; border-radius: 11px; color: white; background: linear-gradient(145deg, #3a7cf0, #1d59cd); box-shadow: 0 7px 16px rgba(40, 105, 232, .22); }
.product-mark svg { width: 19px; }
.eyebrow, .section-kicker { margin: 0; color: #7a88a0; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
h1 { margin: 1px 0 0; font-size: 20px; line-height: 1.15; letter-spacing: -.02em; }
h2 { margin: 1px 0 0; font-size: 17px; line-height: 1.2; }
.environment-chip, .server-state, .status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 29px;
padding: 4px 10px;
border: 1px solid var(--line);
border-radius: 999px;
color: #58677d;
background: #f8fafc;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.environment-chip svg { width: 14px; }
.server-state span { width: 7px; height: 7px; border-radius: 50%; background: #9aa7b8; }
.server-state.online { color: var(--success); border-color: #b9e5d3; background: var(--success-soft); }
.server-state.online span { background: var(--success); box-shadow: 0 0 0 4px rgba(22, 132, 91, .1); }
main {
height: calc(100% - 68px);
min-height: 0;
padding: 11px;
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
grid-template-rows: 116px minmax(0, 1fr) 172px;
grid-template-areas:
"pipeline pipeline"
"command execution"
"software software";
gap: 10px;
}
.card {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 15px;
box-shadow: 0 2px 8px rgba(46, 66, 94, .045);
}
.card-heading { justify-content: space-between; gap: 12px; min-height: 32px; }
.hero-grid { grid-area: command; min-width: 0; min-height: 0; }
.hero-grid .command-card { height: 100%; }
.pipeline-card { grid-area: pipeline; }
.execution-grid { grid-area: execution; min-width: 0; min-height: 0; }
.execution-grid .log-card { width: 100%; height: 100%; }
.software-card { grid-area: software; }
.baseline-picker {
display: grid;
grid-template-columns: auto auto;
align-items: center;
column-gap: 5px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.baseline-picker svg { grid-row: 1 / 3; width: 16px; color: var(--primary); }
.baseline-picker span { line-height: 1; }
.baseline-picker select {
max-width: 148px;
border: 0;
padding: 1px 18px 1px 0;
color: var(--text);
background: transparent;
font-size: 13px;
font-weight: 700;
outline: none;
}
.command-card { display: flex; flex-direction: column; gap: 7px; }
.command-card > label { color: #5d6b80; font-size: 13px; font-weight: 650; }
textarea {
width: 100%;
min-height: 64px;
flex: 1 1 70px;
resize: none;
padding: 9px 11px;
color: var(--text);
background: var(--surface-soft);
border: 1px solid var(--line-strong);
border-radius: 10px;
font-size: 13px;
line-height: 1.55;
outline: none;
}
textarea:focus { border-color: #79a4f4; box-shadow: 0 0 0 3px rgba(40, 105, 232, .1); background: #fff; }
.prompt-templates { display: flex; flex-wrap: wrap; gap: 6px; }
.prompt-templates button {
min-width: 0;
padding: 5px 8px;
border: 1px solid #cbd9ef;
border-radius: 8px;
color: #3563a9;
background: #f3f7ff;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.capability-brief {
min-height: 50px;
justify-content: space-between;
gap: 8px;
padding: 7px 9px;
border: 1px solid #dbe6f6;
border-radius: 10px;
background: #f6f9fe;
}
.capability-brief > div { min-width: 0; display: grid; gap: 1px; }
.capability-brief span { color: var(--muted); font-size: 12px; }
.capability-brief strong { overflow: hidden; text-overflow: ellipsis; color: #344157; font-size: 13px; white-space: nowrap; }
.capability-brief > svg { width: 17px; flex: 0 0 auto; color: #8fa7ca; }
.capability-target { flex: 1; }
.capability-target strong { color: var(--primary); }
.command-footer { justify-content: flex-end; gap: 8px; }
.button-row { gap: 6px; }
.button {
min-height: 33px;
padding: 6px 11px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 9px;
border: 1px solid transparent;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
}
.button svg { width: 15px; }
.button-primary { color: white; background: var(--primary); box-shadow: 0 5px 12px rgba(40, 105, 232, .18); }
.button-secondary { color: #526178; background: #fff; border-color: var(--line-strong); }
.button:disabled, .icon-button:disabled { cursor: not-allowed; opacity: .48; }
.pipeline {
width: calc(100% - 230px);
height: 58px;
margin: 7px 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
list-style: none;
}
.pipeline-stage { position: relative; min-width: 0; padding-top: 2px; text-align: center; }
.pipeline-stage::after { content: ""; position: absolute; top: 14px; left: calc(50% + 15px); width: calc(100% - 30px); height: 2px; background: #dce4ee; }
.pipeline-stage:last-child::after { display: none; }
.stage-node { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto 1px; display: grid; place-items: center; border: 2px solid #d4deea; border-radius: 50%; color: #7b8aa0; background: #fff; }
.stage-node svg { width: 14px; }
.stage-name { display: block; overflow: hidden; text-overflow: ellipsis; font-size: 13px; line-height: 1.15; white-space: nowrap; }
.stage-state { display: block; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font-size: 12px; line-height: 1.1; white-space: nowrap; }
.pipeline-stage[data-status="running"] .stage-node { color: var(--primary); border-color: var(--primary); background: var(--primary-soft); animation: pulse 1.4s infinite; }
.pipeline-stage[data-status="success"] .stage-node { color: var(--success); border-color: #65bd9e; background: var(--success-soft); }
.pipeline-stage[data-status="failed"] .stage-node, .pipeline-stage[data-status="blocked"] .stage-node { color: var(--danger); border-color: #e39aa1; background: #fff0f1; }
.pipeline-stage[data-status="canceled"] .stage-node { color: #7b8798; background: #eef1f5; }
.pipeline-stage[data-status="pending_confirmation"] .stage-node { color: var(--warning); border-color: #ecc080; background: #fff7e9; }
.pipeline-stage[data-status="success"]::after { background: #8bcdb5; }
.release-result {
position: absolute;
top: 43px;
right: 14px;
width: 210px;
min-height: 52px;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 11px;
background: var(--surface-soft);
}
.result-icon { width: 32px; height: 32px; flex: 0 0 auto; color: #748399; border-radius: 9px; background: #e9eef5; }
.result-icon svg { width: 17px; }
.release-result div { min-width: 0; display: grid; }
.release-result small { color: var(--muted); font-size: 12px; }
.release-result strong { overflow: hidden; text-overflow: ellipsis; font-size: 13px; white-space: nowrap; }
.release-result[data-state="success"] { border-color: #b9e5d3; background: var(--success-soft); }
.release-result[data-state="success"] .result-icon { color: var(--success); background: #d2f0e3; }
.release-result[data-state="failed"] { border-color: #efc5c9; background: #fff2f3; }
.status-legend { display: none; }
.log-card { display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 7px; }
.log-actions { gap: 6px; }
.log-actions .icon-button { width: 31px; height: 31px; }
.status-badge { min-height: 25px; padding: 3px 9px; }
.status-badge[data-status="running"] { color: var(--primary); border-color: #b9cff7; background: var(--primary-soft); }
.status-badge[data-status="success"], .status-badge[data-status="published"] { color: var(--success); border-color: #b9e5d3; background: var(--success-soft); }
.status-badge[data-status="failed"], .status-badge[data-status="blocked"] { color: var(--danger); border-color: #efc5c9; background: #fff2f3; }
.status-badge[data-status="ready"], .status-badge[data-status="pending_confirmation"] { color: var(--warning); border-color: #efd3a8; background: #fff7e9; }
.metrics { margin: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
.metrics div { min-width: 0; padding: 5px 7px; border-radius: 8px; background: var(--surface-soft); border: 1px solid #edf1f6; }
.metrics dt { color: var(--muted); font-size: 12px; }
.metrics dd { margin: 1px 0 0; overflow: hidden; text-overflow: ellipsis; font-size: 13px; font-weight: 750; white-space: nowrap; }
.log-workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(220px, .9fr); gap: 8px; }
.log-stream, .log-details { min-height: 0; overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: #f9fbfd; }
.log-stream { padding: 7px 9px; scrollbar-width: thin; }
.empty-state { height: 100%; min-height: 110px; display: grid; place-content: center; justify-items: center; gap: 4px; color: var(--muted); text-align: center; }
.empty-state svg { width: 24px; color: #93a3ba; }
.empty-state strong { color: #4a596f; font-size: 13px; }
.empty-state span { font-size: 12px; }
.log-entry { display: grid; grid-template-columns: 58px 9px minmax(0, 1fr); gap: 7px; padding: 5px 0; border-bottom: 1px solid #e9eef5; }
.log-entry:last-child { border-bottom: 0; }
.log-time { color: #7b889b; font: 12px/1.4 ui-monospace, Consolas, monospace; }
.log-dot { width: 7px; height: 7px; margin-top: 5px; border-radius: 50%; background: #91a0b4; }
.log-entry[data-level="success"] .log-dot { background: var(--success); }
.log-entry[data-level="error"] .log-dot { background: var(--danger); }
.log-entry[data-level="warning"] .log-dot { background: var(--warning); }
.log-copy { min-width: 0; }
.log-copy strong { font-size: 13px; }
.log-copy p { margin: 1px 0 0; color: #5b687b; font-size: 13px; overflow-wrap: anywhere; }
.log-details { display: flex; flex-direction: column; }
.inspector-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--line); background: #f2f5f9; }
.inspector-tabs button { padding: 7px 4px; border: 0; border-bottom: 2px solid transparent; color: #68768b; background: transparent; font-size: 12px; font-weight: 700; }
.inspector-tabs button.active { color: var(--primary); border-bottom-color: var(--primary); background: #fff; }
.inspector-content { min-height: 0; flex: 1; overflow: auto; padding: 7px; }
.empty-inline, .empty-table { padding: 18px; color: var(--muted); text-align: center; font-size: 12px; }
.change-item, .test-item { display: flex; gap: 7px; padding: 6px; border-bottom: 1px solid #e8edf4; }
.change-type, .test-mark { flex: 0 0 auto; height: 21px; padding: 2px 5px; border-radius: 5px; color: var(--primary); background: var(--primary-soft); font-size: 12px; }
.test-mark { width: 21px; display: grid; place-items: center; color: var(--success); background: var(--success-soft); }
.test-mark svg { width: 12px; }
.change-copy, .test-copy { min-width: 0; display: grid; }
.change-copy strong, .test-copy strong { font-size: 12px; overflow-wrap: anywhere; }
.change-copy span, .test-copy span { color: var(--muted); font-size: 12px; }
.build-output, .code-block { margin: 0; color: #26364d; font: 12px/1.55 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.software-card { display: grid; grid-template-rows: 33px minmax(0, 1fr); gap: 5px; }
.server-target { gap: 6px; color: var(--muted); font-size: 12px; }
.server-target svg { width: 15px; color: var(--primary); }
.server-target strong { color: #3b4a61; }
.table-wrap { min-height: 0; overflow: auto; border: 1px solid var(--line); border-radius: 9px; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; }
th { position: sticky; top: 0; z-index: 1; padding: 6px 7px; color: #617086; background: #f2f5f9; text-align: left; font-weight: 750; white-space: nowrap; }
td { padding: 6px 7px; border-top: 1px solid #e8edf4; color: #4d5a6e; vertical-align: middle; }
th:nth-child(1) { width: 14%; } th:nth-child(2) { width: 13%; } th:nth-child(3) { width: 11%; }
th:nth-child(4) { width: 28%; } th:nth-child(5) { width: 8%; } th:nth-child(6) { width: 26%; }
.software-name, .file-cell { min-width: 0; display: grid; }
.software-name strong, .file-cell strong { overflow: hidden; text-overflow: ellipsis; color: #26354b; white-space: nowrap; }
.software-name span, .file-cell span { color: var(--muted); }
.file-cell > span { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cap-tags { display: flex; gap: 3px; flex-wrap: wrap; }
.cap-tags span { padding: 2px 5px; color: #3563a9; background: #eef4ff; border-radius: 5px; white-space: nowrap; }
.hash-value { display: inline; font-family: ui-monospace, Consolas, monospace; white-space: nowrap; }
.table-actions { display: flex; align-items: center; gap: 3px; flex-wrap: nowrap; }
.table-actions button, .table-actions a { min-width: 27px; height: 26px; padding: 3px 5px; display: inline-grid; place-items: center; border: 0; border-radius: 5px; color: #3563a9; background: #edf3ff; font-size: 12px; text-decoration: none; white-space: nowrap; }
.table-actions svg { width: 13px; height: 13px; }
.table-actions .publish-action { color: white; background: var(--primary); }
.table-actions .delete-action { color: var(--danger); background: #fff0f1; }
.table-actions .notify-action {
flex: 0 0 42px;
width: 42px;
min-width: 42px;
height: 34px;
margin-left: auto;
padding: 6px;
border-radius: 8px;
color: #fff;
background: var(--primary);
box-shadow: 0 4px 10px rgba(40, 105, 232, .2);
}
.table-actions .notify-action svg { width: 20px; height: 20px; stroke-width: 2.25; }
.table-actions .notify-action:hover { background: color-mix(in srgb, var(--primary) 86%, #132447); }
.baseline-protection { height: 26px; padding: 3px 6px; display: inline-flex; align-items: center; gap: 3px; color: var(--success); background: var(--success-soft); border-radius: 5px; font-size: 12px; white-space: nowrap; }
.baseline-protection svg { width: 13px; }
.status-badge[data-status="baseline"] { color: var(--success); border-color: #b9e5d3; background: var(--success-soft); }
.modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 24px; background: rgba(31, 43, 61, .34); backdrop-filter: blur(4px); }
.modal-backdrop[hidden] { display: none; }
.modal { width: min(1040px, 100%); max-height: min(820px, calc(100dvh - 48px)); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; background: #fff; border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow); }
.modal header, .modal footer { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); }
.modal footer { justify-content: flex-end; border-top: 1px solid var(--line); border-bottom: 0; }
.modal-body { overflow: auto; padding: 16px; }
.detail-grid, .diff-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.detail-field, .diff-panel { min-width: 0; padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-soft); }
.detail-field span { display: block; color: var(--muted); font-size: 12px; }
.detail-field strong { display: block; margin-top: 3px; overflow-wrap: anywhere; }
.diff-panel h3 { margin: 0 0 8px; font-size: 14px; }
.diff-panel p { margin: 5px 0; color: #58677c; }
.diff-panel.added { border-color: #b9e5d3; background: var(--success-soft); }
.toast-region { position: fixed; z-index: 80; right: 22px; bottom: 22px; display: grid; gap: 8px; }
.toast { max-width: 390px; padding: 10px 13px; color: #fff; background: #344258; border-radius: 9px; box-shadow: 0 10px 28px rgba(31, 43, 61, .22); font-size: 13px; }
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
@keyframes pulse { 50% { box-shadow: 0 0 0 5px rgba(40, 105, 232, .09); } }
@media (max-width: 980px) {
body { padding: 8px; }
.shell { width: 100%; height: calc(100dvh - 16px); min-height: 560px; border-radius: 16px; }
.topbar { height: 62px; padding: 0 14px; }
.topbar-status .dashboard-chip, .environment-chip:last-child { display: none; }
main { height: calc(100% - 62px); grid-template-columns: 330px minmax(0, 1fr); grid-template-rows: 108px minmax(0, 1fr) 164px; padding: 8px; gap: 8px; }
.card { padding: 10px; }
.pipeline { width: calc(100% - 190px); }
.release-result { width: 176px; right: 10px; }
.log-workspace { grid-template-columns: 1fr; }
.log-details { display: none; }
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
body { display: block; padding: 0; }
.shell { height: 100dvh; min-height: 0; border: 0; border-radius: 0; }
.topbar { height: 58px; }
h1 { font-size: 17px; }
.eyebrow, .topbar-status { display: none; }
main { height: calc(100% - 58px); overflow: auto; grid-template-columns: 1fr; grid-template-rows: 104px 360px 330px 190px; grid-template-areas: "pipeline" "command" "execution" "software"; }
.execution-grid { display: block; }
.execution-grid .log-card { min-height: 330px; }
.pipeline { width: 100%; }
.pipeline-card { overflow-x: auto; }
.pipeline-card .pipeline { width: 680px; }
.release-result { display: none; }
.stage-name { font-size: 11px; }
.stage-state { display: none; }
.command-card { gap: 5px; }
.command-footer p { display: none; }
.command-footer { justify-content: flex-end; }
.prompt-templates button { flex: 1; }
th:nth-child(2), td:nth-child(2), th:nth-child(3), td:nth-child(3) { display: none; }
th:nth-child(1), th:nth-child(4), th:nth-child(5), th:nth-child(6) { width: auto; }
}
body.embedded {
display: block;
padding: 0;
background: transparent;
}
body.embedded .shell {
width: 100%;
height: 100dvh;
min-height: 0;
border: 0;
border-radius: 0;
box-shadow: none;
background: #f7f9fc;
}
body.embedded .topbar { display: none; }
body.embedded main {
height: 100%;
padding: 8px;
grid-template-columns: 350px minmax(0, 1fr);
grid-template-rows: 82px minmax(0, 1fr) 204px;
gap: 8px;
}
body.embedded .card { padding: 8px 10px; border-radius: 12px; }
body.embedded .card-heading { min-height: 27px; }
body.embedded .command-card { gap: 5px; }
body.embedded textarea { min-height: 48px; padding: 7px 9px; }
body.embedded .prompt-templates button { padding: 4px 7px; }
body.embedded .capability-brief { min-height: 44px; padding: 5px 8px; }
body.embedded .pipeline { height: 48px; margin: 4px 0 0; }
body.embedded .stage-node { width: 24px; height: 24px; }
body.embedded .pipeline-stage::after { top: 12px; }
body.embedded .stage-node svg { width: 12px; }
body.embedded .stage-name { font-size: 12px; }
body.embedded .stage-state { font-size: 11px; }
body.embedded .release-result { top: 18px; min-height: 44px; padding: 5px 8px; }
body.embedded .result-icon { width: 28px; height: 28px; }
body.embedded .log-card { gap: 5px; }
body.embedded .metrics div { padding: 3px 6px; }
body.embedded .software-card { grid-template-rows: 27px minmax(0, 1fr); gap: 3px; }
body.embedded th { padding: 4px 6px; }
body.embedded table { font-size: 12.5px; }
body.embedded td { padding: 3px 6px; }
body.embedded .software-name strong,
body.embedded .file-cell strong { line-height: 1.2; }
body.embedded .software-name span,
body.embedded .file-cell span { line-height: 1.15; }
@media (max-width: 720px) {
body.embedded main {
height: 100%;
overflow: auto;
grid-template-columns: 1fr;
grid-template-rows: 96px 350px 320px 210px;
grid-template-areas: "pipeline" "command" "execution" "software";
}
body.embedded .pipeline-card { overflow-x: auto; }
body.embedded .pipeline-card .pipeline { width: 680px; }
}
/* The workbench is rendered in an iframe, so it owns a complete theme token
set instead of relying on custom properties from the parent document. */
:root[data-glass-theme="dark"] {
color-scheme: dark;
--page: #090e1c;
--surface: #171e31;
--surface-soft: #11182a;
--line: #303a55;
--line-strong: #44516d;
--text: #edf2ff;
--muted: #9ba9c0;
--primary: #7aa3ff;
--primary-soft: #1b2b53;
--success: #55d4a1;
--success-soft: #12372d;
--warning: #f2bd68;
--danger: #ff8993;
--shadow: 0 26px 70px rgba(0, 0, 0, .42), 0 4px 18px rgba(0, 0, 0, .24);
}
:root[data-glass-theme="dark"] body:not(.embedded) {
background:
radial-gradient(circle at 15% 8%, color-mix(in srgb, var(--primary) 18%, transparent), transparent 31%),
linear-gradient(145deg, #11182b 0%, var(--page) 100%);
}
:root[data-glass-theme="dark"] body.embedded { background: transparent; }
:root[data-glass-theme="dark"] .shell,
:root[data-glass-theme="dark"] body.embedded .shell {
background: color-mix(in srgb, var(--surface-soft) 86%, var(--page));
border-color: color-mix(in srgb, var(--line-strong) 78%, transparent);
}
:root[data-glass-theme="dark"] .topbar {
background: color-mix(in srgb, var(--surface) 94%, transparent);
}
:root[data-glass-theme="dark"] :is(.back-link, .icon-button, .button-secondary) {
color: var(--muted);
background: var(--surface);
border-color: var(--line-strong);
}
:root[data-glass-theme="dark"] :is(.back-link, .icon-button):hover {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 50%, var(--line));
background: var(--primary-soft);
}
:root[data-glass-theme="dark"] :is(.eyebrow, .section-kicker) { color: var(--muted); }
:root[data-glass-theme="dark"] :is(.environment-chip, .server-state) {
color: var(--muted);
background: var(--surface-soft);
}
:root[data-glass-theme="dark"] .server-state span { background: color-mix(in srgb, var(--muted) 72%, transparent); }
:root[data-glass-theme="dark"] .server-state.online,
:root[data-glass-theme="dark"] .status-badge[data-status="baseline"] {
border-color: color-mix(in srgb, var(--success) 44%, var(--line));
background: var(--success-soft);
}
:root[data-glass-theme="dark"] .card {
box-shadow: 0 2px 10px rgba(0, 0, 0, .16);
}
:root[data-glass-theme="dark"] .command-card > label { color: var(--muted); }
:root[data-glass-theme="dark"] textarea:focus { background: var(--surface); }
:root[data-glass-theme="dark"] :is(select, option) { color: var(--text); background: var(--surface); }
:root[data-glass-theme="dark"] .prompt-templates button,
:root[data-glass-theme="dark"] .cap-tags span,
:root[data-glass-theme="dark"] .table-actions :is(button, a) {
color: var(--primary);
background: var(--primary-soft);
border-color: color-mix(in srgb, var(--primary) 34%, var(--line));
}
:root[data-glass-theme="dark"] .capability-brief {
border-color: color-mix(in srgb, var(--primary) 25%, var(--line));
background: color-mix(in srgb, var(--primary-soft) 44%, var(--surface-soft));
}
:root[data-glass-theme="dark"] .capability-brief strong { color: var(--text); }
:root[data-glass-theme="dark"] .capability-brief > svg { color: var(--primary); }
:root[data-glass-theme="dark"] .pipeline-stage::after { background: var(--line); }
:root[data-glass-theme="dark"] .stage-node {
color: var(--muted);
border-color: var(--line-strong);
background: var(--surface);
}
:root[data-glass-theme="dark"] .pipeline-stage[data-status="success"] .stage-node {
border-color: color-mix(in srgb, var(--success) 62%, var(--line));
}
:root[data-glass-theme="dark"] .pipeline-stage[data-status="failed"] .stage-node,
:root[data-glass-theme="dark"] .pipeline-stage[data-status="blocked"] .stage-node,
:root[data-glass-theme="dark"] .release-result[data-state="failed"],
:root[data-glass-theme="dark"] .status-badge[data-status="failed"],
:root[data-glass-theme="dark"] .status-badge[data-status="blocked"] {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 44%, var(--line));
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
}
:root[data-glass-theme="dark"] .pipeline-stage[data-status="canceled"] .stage-node {
color: var(--muted);
background: var(--surface-soft);
}
:root[data-glass-theme="dark"] .pipeline-stage[data-status="pending_confirmation"] .stage-node,
:root[data-glass-theme="dark"] .status-badge[data-status="ready"],
:root[data-glass-theme="dark"] .status-badge[data-status="pending_confirmation"] {
color: var(--warning);
border-color: color-mix(in srgb, var(--warning) 44%, var(--line));
background: color-mix(in srgb, var(--warning) 12%, var(--surface));
}
:root[data-glass-theme="dark"] .pipeline-stage[data-status="success"]::after {
background: color-mix(in srgb, var(--success) 64%, var(--line));
}
:root[data-glass-theme="dark"] .release-result[data-state="success"],
:root[data-glass-theme="dark"] .status-badge[data-status="success"],
:root[data-glass-theme="dark"] .status-badge[data-status="published"] {
border-color: color-mix(in srgb, var(--success) 44%, var(--line));
background: var(--success-soft);
}
:root[data-glass-theme="dark"] .result-icon {
color: var(--muted);
background: color-mix(in srgb, var(--surface-soft) 78%, var(--line));
}
:root[data-glass-theme="dark"] .release-result[data-state="success"] .result-icon {
color: var(--success);
background: color-mix(in srgb, var(--success) 18%, var(--surface));
}
:root[data-glass-theme="dark"] .metrics div { border-color: var(--line); }
:root[data-glass-theme="dark"] :is(.log-stream, .log-details) { background: var(--surface-soft); }
:root[data-glass-theme="dark"] .empty-state svg { color: var(--muted); }
:root[data-glass-theme="dark"] .empty-state strong { color: var(--text); }
:root[data-glass-theme="dark"] .log-entry,
:root[data-glass-theme="dark"] :is(.change-item, .test-item) { border-color: var(--line); }
:root[data-glass-theme="dark"] .log-time { color: var(--muted); }
:root[data-glass-theme="dark"] .log-dot { background: var(--muted); }
:root[data-glass-theme="dark"] .log-copy p { color: var(--muted); }
:root[data-glass-theme="dark"] .inspector-tabs { background: var(--surface); }
:root[data-glass-theme="dark"] .inspector-tabs button { color: var(--muted); }
:root[data-glass-theme="dark"] .inspector-tabs button.active {
color: var(--primary);
background: var(--surface-soft);
}
:root[data-glass-theme="dark"] :is(.build-output, .code-block) { color: var(--text); }
:root[data-glass-theme="dark"] .server-target strong { color: var(--text); }
:root[data-glass-theme="dark"] th { color: var(--muted); background: var(--surface); }
:root[data-glass-theme="dark"] td { color: var(--muted); border-color: var(--line); }
:root[data-glass-theme="dark"] :is(.software-name strong, .file-cell strong) { color: var(--text); }
:root[data-glass-theme="dark"] .table-actions .delete-action {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
}
:root[data-glass-theme="dark"] .table-actions .notify-action {
color: #fff;
background: color-mix(in srgb, var(--primary) 82%, #07101e);
}
:root[data-glass-theme="dark"] .modal-backdrop { background: rgba(2, 5, 14, .68); }
:root[data-glass-theme="dark"] .modal { background: var(--surface); }
:root[data-glass-theme="dark"] .diff-panel p { color: var(--muted); }
:root[data-glass-theme="dark"] .diff-panel.added {
border-color: color-mix(in srgb, var(--success) 42%, var(--line));
}
+145
View File
@@ -0,0 +1,145 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#0f1526" id="firmwareWorkbenchThemeColor" />
<title>座椅固件开发工作台 · 511 工程</title>
<link rel="icon" href="/icons/app-icon-192.png" />
<script src="/firmware-workbench-theme.js?v=1"></script>
<link rel="stylesheet" href="/firmware-workbench.css?v=8" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/firmware-workbench.js?v=5" defer></script>
</head>
<body>
<div class="shell">
<header class="topbar">
<div class="topbar-main">
<a class="back-link" href="/" aria-label="返回智能座舱">
<i data-lucide="arrow-left"></i>
</a>
<div class="product-mark" aria-hidden="true"><i data-lucide="blocks"></i></div>
<h1>座椅固件开发工作台</h1>
</div>
<div class="topbar-status">
<span class="server-state" id="serverState"><span></span>正在连接发布服务</span>
</div>
</header>
<main>
<section class="hero-grid" aria-label="固件开发任务">
<article class="card command-card">
<div class="card-heading">
<div>
<h2>开发指令</h2>
</div>
<label class="baseline-picker" for="baselineSelect">
<i data-lucide="git-branch"></i>
<span>固件基线</span>
<select id="baselineSelect" aria-label="选择固件基线">
<option value="time_only">时间控制</option>
</select>
</label>
</div>
<label for="instructionInput">描述需要扩展的副驾座椅能力</label>
<textarea id="instructionInput" rows="5" maxlength="2000">将副驾座椅固件从仅时间控制扩展为时间+位置参数控制,并增加座椅通风和座椅加热功能;完成编译、测试验证、固件打包,并发布到升级服务器。</textarea>
<div class="prompt-templates" aria-label="常用开发指令">
<button type="button" data-prompt="增加支持座椅位置参数控制的能力">+ 位置控制</button>
<button type="button" data-prompt="增加座椅通风功能">+ 座椅通风</button>
<button type="button" data-prompt="增加座椅加热功能">+ 座椅加热</button>
<button type="button" data-prompt="增加动态木马功能">+ 动态木马</button>
</div>
<div class="capability-brief" aria-label="固件能力升级范围">
<div><span>当前基线</span><strong id="baselineCapabilities">时间控制</strong></div>
<i data-lucide="chevrons-right"></i>
<div class="capability-target"><span>目标固件能力</span><strong id="targetCapabilities">时间控制+位置控制+座椅通风+座椅加热</strong></div>
</div>
<div class="command-footer">
<div class="button-row">
<button class="button button-secondary" id="cancelTaskButton" type="button" disabled>
<i data-lucide="square"></i>取消任务
</button>
<button class="button button-primary" id="startTaskButton" type="button">
<i data-lucide="play"></i>开始生成
</button>
</div>
</div>
</article>
</section>
<section class="card pipeline-card" aria-label="固件交付流水线">
<ol class="pipeline" id="pipeline" aria-label="固件交付阶段"></ol>
<div class="release-result" id="releaseResult" data-state="idle">
<span class="result-icon"><i data-lucide="cloud-upload"></i></span>
<div><small>当前交付状态</small><strong>尚未开始</strong></div>
</div>
<div class="status-legend" aria-label="阶段状态图例">
<span data-status="waiting">等待</span><span data-status="running">运行中</span>
<span data-status="pending_confirmation">待确认</span><span data-status="success">成功</span>
<span data-status="failed">失败</span><span data-status="canceled">取消</span>
<span data-status="blocked">阻塞</span>
</div>
</section>
<section class="execution-grid">
<article class="card log-card" aria-labelledby="logTitle">
<div class="card-heading">
<div>
<h2 id="logTitle">任务执行日志</h2>
</div>
<div class="log-actions">
<span class="status-badge" id="taskStatusBadge" data-status="waiting">等待</span>
<button class="icon-button" id="retryTaskButton" type="button" title="重试失败任务" disabled><i data-lucide="refresh-cw"></i></button>
<button class="icon-button" id="scrollLogButton" type="button" title="滚动到最新日志"><i data-lucide="arrow-down-to-line"></i></button>
</div>
</div>
<dl class="metrics" id="taskMetrics">
<div><dt>开始时间</dt><dd>—</dd></div><div><dt>结束时间</dt><dd>—</dd></div>
<div><dt>实际耗时</dt><dd>—</dd></div><div><dt>文件变更</dt><dd>—</dd></div>
</dl>
<div class="log-workspace">
<div class="log-stream" id="logStream" role="log" aria-live="polite">
<div class="empty-state"><i data-lucide="terminal-square"></i><strong>暂无执行记录</strong><span>创建开发任务后,这里将实时显示各阶段输出。</span></div>
</div>
<aside class="log-details" aria-label="日志附属详情">
<div class="inspector-tabs" role="tablist">
<button class="active" type="button" role="tab" data-inspector-tab="changes">文件变更</button>
<button type="button" role="tab" data-inspector-tab="build">编译输出</button>
<button type="button" role="tab" data-inspector-tab="tests">测试验证</button>
</div>
<div class="inspector-content" id="inspectorContent">
<div class="empty-inline">暂无任务数据</div>
</div>
</aside>
</div>
</article>
</section>
<section class="card software-card" aria-labelledby="softwareTitle">
<div class="card-heading software-heading">
<div>
<h2 id="softwareTitle">固件版本与软件产物</h2>
</div>
<div class="server-target"><i data-lucide="server"></i><span>发布目标</span><strong id="otaServerName">车机升级服务器</strong></div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>软件 / 版本</th><th>能力</th><th>构建时间</th><th>固件文件 / 校验</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="artifactTableBody"><tr><td colspan="6"><div class="empty-table">尚未生成固件产物</div></td></tr></tbody>
</table>
</div>
</section>
</main>
</div>
<div class="modal-backdrop" id="detailModal" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<header><h2 id="modalTitle">固件详情</h2><button class="icon-button" id="closeModalButton" type="button" aria-label="关闭"><i data-lucide="x"></i></button></header>
<div class="modal-body" id="modalBody"></div>
<footer><button class="button button-secondary" id="modalCloseButton" type="button">关闭</button></footer>
</section>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
</body>
</html>
+464
View File
@@ -0,0 +1,464 @@
(() => {
"use strict";
if (window.self !== window.top || new URLSearchParams(window.location.search).has("embedded")) {
document.body.classList.add("embedded");
}
const STATUS_LABELS = {
waiting: "等待",
running: "运行中",
pending_confirmation: "待确认",
success: "成功",
failed: "失败",
canceled: "取消",
blocked: "阻塞",
published: "已发布",
ready: "待发布",
baseline: "基线固件",
};
const STAGE_ICONS = {
instruction: "file-input",
requirements: "list-checks",
baseline: "git-compare-arrows",
codegen: "code-2",
build: "hammer",
test: "shield-check",
package: "package-check",
publish: "cloud-upload",
};
const ACTIVE_STATES = new Set(["waiting", "running", "pending_confirmation"]);
const CAPABILITY_ORDER = ["时间控制", "位置控制", "座椅通风", "座椅加热", "动态木马"];
const FALLBACK_BASELINES = {
time_only: ["时间控制"],
};
const FALLBACK_PIPELINE = [
["instruction", "指令输入"], ["requirements", "需求解析"],
["baseline", "基线检查"], ["codegen", "代码生成"],
["build", "编译构建"], ["test", "测试验证"],
["package", "固件打包"], ["publish", "发布服务器"],
].map(([id, name]) => ({ id, name, status: "waiting", summary: "等待执行" }));
const ui = {
instruction: document.querySelector("#instructionInput"),
baseline: document.querySelector("#baselineSelect"),
baselineCapabilities: document.querySelector("#baselineCapabilities"),
targetCapabilities: document.querySelector("#targetCapabilities"),
start: document.querySelector("#startTaskButton"),
cancel: document.querySelector("#cancelTaskButton"),
retry: document.querySelector("#retryTaskButton"),
server: document.querySelector("#serverState"),
serverName: document.querySelector("#otaServerName"),
release: document.querySelector("#releaseResult"),
pipeline: document.querySelector("#pipeline"),
logs: document.querySelector("#logStream"),
scrollLogs: document.querySelector("#scrollLogButton"),
status: document.querySelector("#taskStatusBadge"),
metrics: document.querySelector("#taskMetrics"),
inspector: document.querySelector("#inspectorContent"),
artifacts: document.querySelector("#artifactTableBody"),
modal: document.querySelector("#detailModal"),
modalTitle: document.querySelector("#modalTitle"),
modalBody: document.querySelector("#modalBody"),
modalClose: document.querySelector("#closeModalButton"),
modalCloseFooter: document.querySelector("#modalCloseButton"),
toasts: document.querySelector("#toastRegion"),
};
const model = {
snapshot: { tasks: [], artifacts: [], releases: [], pipeline: FALLBACK_PIPELINE },
selectedTaskId: null,
inspectorTab: "changes",
pending: false,
pollTimer: null,
lastLogCount: 0,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json") ? await response.json() : null;
if (!response.ok) throw new Error(payload?.message || `请求失败(${response.status})`);
return payload;
}
function selectedTask() {
return model.snapshot.tasks.find((task) => task.id === model.selectedTaskId)
|| model.snapshot.tasks[0]
|| null;
}
function baselineCapabilities() {
const baseline = model.snapshot.baselines?.find((item) => item.id === ui.baseline.value);
return baseline?.capabilities || FALLBACK_BASELINES[ui.baseline.value] || FALLBACK_BASELINES.time_only;
}
function parseInstructionCapabilities(instruction) {
const lowered = String(instruction || "").toLowerCase();
const aliases = {
"时间控制": ["时间", "定时", "timed"],
"位置控制": ["位置", "position"],
"座椅通风": ["通风", "vent"],
"座椅加热": ["加热", "heater", "heat"],
"动态木马": ["木马", "rocking_horse", "rocking horse", "rocking-horse"],
};
const requested = CAPABILITY_ORDER.filter((capability) => aliases[capability].some((alias) => lowered.includes(alias)));
if (requested.includes("动态木马") && !requested.includes("位置控制")) requested.push("位置控制");
return CAPABILITY_ORDER.filter((capability) => requested.includes(capability));
}
function updateCapabilityPreview(task = null) {
const baseline = baselineCapabilities();
const taskMatchesDraft = task
&& task.instruction === ui.instruction.value.trim()
&& (task.baseline_id || "time_only") === ui.baseline.value;
const target = taskMatchesDraft
? task.target_capabilities
: CAPABILITY_ORDER.filter((item) => baseline.includes(item) || parseInstructionCapabilities(ui.instruction.value).includes(item));
ui.baselineCapabilities.textContent = baseline.join("+");
ui.targetCapabilities.textContent = (target?.length ? target : baseline).join("+");
ui.targetCapabilities.title = ui.targetCapabilities.textContent;
}
function formatDate(value) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
hour12: false,
}).format(date).replaceAll("/", "-");
}
function formatClock(value) {
if (!value) return "--:--:--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--:--:--";
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
}).format(date);
}
function formatDuration(milliseconds) {
if (milliseconds === null || milliseconds === undefined) return "—";
if (milliseconds < 1000) return `${milliseconds} 毫秒`;
const seconds = milliseconds / 1000;
return seconds < 60 ? `${seconds.toFixed(seconds < 10 ? 1 : 0)} 秒` : `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`;
}
function formatSize(bytes) {
const value = Number(bytes || 0);
if (value < 1024) return `${value} 字节`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} 千字节`;
return `${(value / 1024 / 1024).toFixed(2)} 兆字节`;
}
function toast(message, type = "info") {
const element = document.createElement("div");
element.className = `toast ${type}`;
element.textContent = message;
ui.toasts.append(element);
window.setTimeout(() => element.remove(), 4200);
}
function refreshIcons() {
if (window.lucide) window.lucide.createIcons({ attrs: { "stroke-width": 1.8 } });
}
function renderPipeline(task) {
const stages = task?.stages || model.snapshot.pipeline?.map((stage) => ({ ...stage, status: "waiting" })) || FALLBACK_PIPELINE;
ui.pipeline.innerHTML = stages.map((stage) => `
<li class="pipeline-stage" data-status="${escapeHtml(stage.status || "waiting")}" title="${escapeHtml(stage.summary || "")}">
<span class="stage-node"><i data-lucide="${STAGE_ICONS[stage.id] || "circle"}"></i></span>
<strong class="stage-name">${escapeHtml(stage.name)}</strong>
<span class="stage-state">${escapeHtml(STATUS_LABELS[stage.status] || stage.status || "等待")}${stage.duration_ms !== null && stage.duration_ms !== undefined ? ` · ${escapeHtml(formatDuration(stage.duration_ms))}` : ""}</span>
</li>`).join("");
}
function renderRelease(task) {
let icon = "cloud-upload";
let label = "尚未开始";
let state = "idle";
if (task) {
label = task.message || STATUS_LABELS[task.status] || task.status;
state = task.status;
if (task.status === "success") { icon = "circle-check-big"; state = "success"; }
else if (task.status === "running") icon = "loader-circle";
else if (task.status === "failed") icon = "circle-x";
else if (task.status === "canceled") icon = "ban";
}
ui.release.dataset.state = state;
ui.release.innerHTML = `<span class="result-icon"><i data-lucide="${icon}"></i></span><div><small>当前交付状态</small><strong>${escapeHtml(label)}</strong></div>`;
}
function renderLogs(task) {
const logs = task?.logs || [];
if (!logs.length) {
ui.logs.innerHTML = `<div class="empty-state"><i data-lucide="terminal-square"></i><strong>暂无执行记录</strong><span>创建开发任务后,这里将实时显示各阶段输出。</span></div>`;
model.lastLogCount = 0;
return;
}
const nearBottom = ui.logs.scrollHeight - ui.logs.scrollTop - ui.logs.clientHeight < 80;
ui.logs.innerHTML = logs.map((entry) => `
<article class="log-entry" data-level="${escapeHtml(entry.level)}">
<time class="log-time">${escapeHtml(formatClock(entry.time))}</time>
<span class="log-dot"></span>
<div class="log-copy"><strong>${escapeHtml(entry.title)}</strong><p>${escapeHtml(entry.message)}</p></div>
</article>`).join("");
if (nearBottom || logs.length > model.lastLogCount) ui.logs.scrollTop = ui.logs.scrollHeight;
model.lastLogCount = logs.length;
}
function renderInspector(task) {
const status = task?.status || "waiting";
ui.status.dataset.status = status;
ui.status.textContent = STATUS_LABELS[status] || status;
const fileCount = task?.file_changes?.length || 0;
ui.metrics.innerHTML = `
<div><dt>开始时间</dt><dd>${escapeHtml(formatDate(task?.started_at))}</dd></div>
<div><dt>结束时间</dt><dd>${escapeHtml(formatDate(task?.finished_at))}</dd></div>
<div><dt>实际耗时</dt><dd>${escapeHtml(formatDuration(task?.duration_ms))}</dd></div>
<div><dt>文件变更</dt><dd>${fileCount ? `${fileCount} 个文件` : "—"}</dd></div>`;
document.querySelectorAll("[data-inspector-tab]").forEach((button) => {
button.classList.toggle("active", button.dataset.inspectorTab === model.inspectorTab);
});
if (!task) {
ui.inspector.innerHTML = `<div class="empty-inline">暂无任务数据</div>`;
return;
}
if (model.inspectorTab === "build") {
ui.inspector.innerHTML = task.build_output?.length
? `<pre class="build-output">${escapeHtml(task.build_output.join("\n"))}</pre>`
: `<div class="empty-inline">尚无编译输出</div>`;
return;
}
if (model.inspectorTab === "tests") {
ui.inspector.innerHTML = task.test_results?.length
? task.test_results.map((test) => `<div class="test-item"><span class="test-mark"><i data-lucide="check"></i></span><div class="test-copy"><strong>${escapeHtml(test.name)}</strong><span>${escapeHtml(test.summary)}</span></div></div>`).join("")
: `<div class="empty-inline">测试尚未执行</div>`;
return;
}
ui.inspector.innerHTML = task.file_changes?.length
? task.file_changes.map((change) => `<div class="change-item"><span class="change-type">${change.type === "added" ? "新增" : change.type === "retained" ? "复用" : "修改"}</span><div class="change-copy"><strong>${escapeHtml(change.path)}</strong><span>${escapeHtml(change.summary)}</span></div></div>`).join("")
: `<div class="empty-inline">尚无文件变更</div>`;
}
function renderArtifacts() {
const artifacts = model.snapshot.artifacts || [];
if (!artifacts.length) {
ui.artifacts.innerHTML = `<tr><td colspan="6"><div class="empty-table">尚未生成固件产物</div></td></tr>`;
return;
}
ui.artifacts.innerHTML = artifacts.map((artifact) => `
<tr>
<td><div class="software-name"><strong>${escapeHtml(artifact.software_name)}</strong><span>${escapeHtml(artifact.version)}</span></div></td>
<td><div class="cap-tags">${(artifact.capabilities || []).map((capability) => `<span>${escapeHtml(capability)}</span>`).join("")}</div></td>
<td>${escapeHtml(formatDate(artifact.build_time))}</td>
<td><div class="file-cell"><strong>${escapeHtml(artifact.firmware_filename)}</strong><span>${escapeHtml(formatSize(artifact.size_bytes))} · <span class="hash-value" title="${escapeHtml(artifact.sha256)}">${escapeHtml(artifact.sha256)}</span></span></div></td>
<td><span class="status-badge" data-status="${escapeHtml(artifact.status)}">${escapeHtml(STATUS_LABELS[artifact.status] || artifact.status)}</span></td>
<td><div class="table-actions">
<button type="button" title="查看详情" data-artifact-action="detail" data-artifact-id="${artifact.id}">详情</button>
<button type="button" title="查看能力差异" data-artifact-action="capabilities" data-artifact-id="${artifact.id}">能力</button>
<button type="button" title="查看代码差异" data-artifact-action="code" data-artifact-id="${artifact.id}">代码</button>
<button type="button" title="查看验证记录" data-artifact-action="validation" data-artifact-id="${artifact.id}">验证</button>
<a href="${escapeHtml(artifact.download_url)}" title="下载固件" aria-label="下载固件" download><i data-lucide="download"></i></a>
<button class="publish-action" type="button" title="发布到服务器" data-artifact-action="publish" data-artifact-id="${artifact.id}"><i data-lucide="cloud-upload"></i></button>
${artifact.is_baseline ? `<span class="baseline-protection" title="时间控制基线固件受保护"><i data-lucide="shield-check"></i>受保护</span>` : `<button class="delete-action" type="button" title="删除固件产物" data-artifact-action="delete" data-artifact-id="${artifact.id}"><i data-lucide="trash-2"></i></button>`}
${artifact.status === "published" ? `<button class="notify-action" type="button" title="通知车端" aria-label="通知车端" data-artifact-action="notify" data-artifact-id="${artifact.id}"><i data-lucide="bell"></i></button>` : ""}
</div></td>
</tr>`).join("");
}
function render() {
const task = selectedTask();
if (task) model.selectedTaskId = task.id;
renderPipeline(task);
renderRelease(task);
renderLogs(task);
renderInspector(task);
renderArtifacts();
updateCapabilityPreview(task);
const active = task && ACTIVE_STATES.has(task.status);
ui.start.disabled = model.pending || Boolean(active);
ui.cancel.disabled = model.pending || !active;
ui.retry.disabled = model.pending || !task || !["failed", "canceled", "blocked"].includes(task.status);
ui.start.innerHTML = model.pending ? `<i data-lucide="loader-circle"></i>正在提交` : `<i data-lucide="play"></i>开始生成`;
refreshIcons();
}
async function refreshState({ quiet = false } = {}) {
try {
model.snapshot = await api("/api/firmware-workbench/state");
if (!model.selectedTaskId && model.snapshot.tasks?.length) model.selectedTaskId = model.snapshot.tasks[0].id;
ui.server.classList.add("online");
ui.server.innerHTML = `<span></span>发布服务已连接`;
ui.serverName.textContent = model.snapshot.ota_server?.name || "车机升级服务器";
render();
} catch (error) {
ui.server.classList.remove("online");
ui.server.innerHTML = `<span></span>发布服务连接失败`;
if (!quiet) toast(error.message, "error");
} finally {
schedulePoll();
}
}
function schedulePoll() {
window.clearTimeout(model.pollTimer);
const active = model.snapshot.tasks?.some((task) => ACTIVE_STATES.has(task.status));
model.pollTimer = window.setTimeout(() => refreshState({ quiet: true }), active ? 700 : 5000);
}
async function startTask() {
const instruction = ui.instruction.value.trim();
if (!instruction) { toast("请输入开发指令。", "error"); ui.instruction.focus(); return; }
model.pending = true;
render();
try {
const result = await api("/api/firmware-workbench/tasks", {
method: "POST", body: JSON.stringify({ instruction, baseline_id: ui.baseline.value }),
});
model.selectedTaskId = result.task.id;
toast("开发任务已创建,流水线开始执行。", "success");
await refreshState({ quiet: true });
} catch (error) {
toast(error.message, "error");
} finally {
model.pending = false;
render();
}
}
async function taskAction(action) {
const task = selectedTask();
if (!task) return;
model.pending = true;
render();
try {
await api(`/api/firmware-workbench/tasks/${encodeURIComponent(task.id)}/${action}`, { method: "POST", body: "{}" });
toast(action === "cancel" ? "取消请求已提交。" : "任务已重新进入流水线。", "success");
await refreshState({ quiet: true });
} catch (error) {
toast(error.message, "error");
} finally {
model.pending = false;
render();
}
}
function appendPrompt(text) {
const current = ui.instruction.value.trim();
if (current.includes(text)) return;
ui.instruction.value = current ? `${current.replace(/[。;;]?$/, "")};${text}。` : `${text}。`;
ui.instruction.focus();
updateCapabilityPreview();
}
function showModal(title, content) {
ui.modalTitle.textContent = title;
ui.modalBody.innerHTML = content;
ui.modal.hidden = false;
document.body.style.overflow = "hidden";
refreshIcons();
ui.modalClose.focus();
}
function closeModal() {
ui.modal.hidden = true;
document.body.style.overflow = "";
}
async function artifactAction(button) {
const artifact = model.snapshot.artifacts.find((item) => item.id === button.dataset.artifactId);
if (!artifact) return;
const action = button.dataset.artifactAction;
if (action === "delete") {
if (!window.confirm(`确认删除“${artifact.firmware_filename}”及其服务器发布记录吗?此操作不可恢复。`)) return;
button.disabled = true;
try {
const result = await api(`/api/firmware-workbench/artifacts/${artifact.id}`, { method: "DELETE" });
toast(result.message, "success");
await refreshState({ quiet: true });
} catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; }
return;
}
if (action === "publish") {
button.disabled = true;
try {
const release = await api(`/api/firmware-workbench/artifacts/${artifact.id}/publish`, { method: "POST", body: "{}" });
toast(`${release.message}(${release.id})`, "success");
await refreshState({ quiet: true });
} catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; }
return;
}
if (action === "notify") {
button.disabled = true;
try {
const result = await api(`/api/firmware-workbench/artifacts/${artifact.id}/notify`, { method: "POST", body: "{}" });
toast(`已向车端发送 ${result.published_count || result.burst_count || 3} 帧通知。`, "success");
await refreshState({ quiet: true });
} catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; }
return;
}
if (action === "detail") {
showModal("固件产物详情", `<div class="detail-grid">
<div class="detail-field"><span>软件名称</span><strong>${escapeHtml(artifact.software_name)}</strong></div>
<div class="detail-field"><span>版本</span><strong>${escapeHtml(artifact.version)}</strong></div>
<div class="detail-field"><span>固件包</span><strong>${escapeHtml(artifact.firmware_filename)}</strong></div>
<div class="detail-field"><span>构建时间</span><strong>${escapeHtml(formatDate(artifact.build_time))}</strong></div>
<div class="detail-field"><span>固件包大小</span><strong>${escapeHtml(formatSize(artifact.size_bytes))}</strong></div>
<div class="detail-field"><span>发布状态</span><strong>${escapeHtml(STATUS_LABELS[artifact.status] || artifact.status)}</strong></div>
<div class="detail-field"><span>SHA-256(固件包)</span><strong>${escapeHtml(artifact.sha256)}</strong></div>
<div class="detail-field"><span>SHA-256(HEX 镜像)</span><strong>${escapeHtml(artifact.image_sha256)}</strong></div>
</div>`);
} else if (action === "capabilities") {
const diff = artifact.capability_diff || {};
showModal("能力差异", `<div class="diff-columns">
<div class="diff-panel"><h3>固件基线</h3>${(artifact.baseline_capabilities || []).map((item) => `<p>• ${escapeHtml(item)}</p>`).join("")}</div>
<div class="diff-panel added"><h3>目标固件变化</h3>${(diff.added || []).map((item) => `<p>+ ${escapeHtml(item)}</p>`).join("")}${(diff.retained || []).map((item) => `<p>✓ 保留${escapeHtml(item)}</p>`).join("") || "<p>无新增能力</p>"}</div>
</div>`);
} else if (action === "code") {
showModal("代码差异", `<pre class="code-block">${escapeHtml(artifact.code_diff || "暂无代码差异记录")}</pre>`);
} else if (action === "validation") {
showModal("验证记录", (artifact.validation_records || []).map((test) => `<div class="test-item"><span class="test-mark"><i data-lucide="check"></i></span><div class="test-copy"><strong>${escapeHtml(test.name)}</strong><span>${escapeHtml(test.summary)} · ${escapeHtml(formatDate(test.finished_at))}</span></div></div>`).join("") || `<div class="empty-inline">暂无验证记录</div>`);
}
}
ui.start.addEventListener("click", startTask);
ui.instruction.addEventListener("input", () => updateCapabilityPreview());
ui.baseline.addEventListener("change", () => updateCapabilityPreview());
ui.cancel.addEventListener("click", () => taskAction("cancel"));
ui.retry.addEventListener("click", () => taskAction("retry"));
ui.scrollLogs.addEventListener("click", () => { ui.logs.scrollTop = ui.logs.scrollHeight; });
document.querySelectorAll("[data-prompt]").forEach((button) => button.addEventListener("click", () => appendPrompt(button.dataset.prompt)));
document.querySelectorAll("[data-inspector-tab]").forEach((button) => button.addEventListener("click", () => { model.inspectorTab = button.dataset.inspectorTab; renderInspector(selectedTask()); refreshIcons(); }));
ui.artifacts.addEventListener("click", (event) => {
const button = event.target.closest("[data-artifact-action]");
if (button) artifactAction(button);
});
ui.modalClose.addEventListener("click", closeModal);
ui.modalCloseFooter.addEventListener("click", closeModal);
ui.modal.addEventListener("click", (event) => { if (event.target === ui.modal) closeModal(); });
document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !ui.modal.hidden) closeModal(); });
renderPipeline(null);
renderRelease(null);
updateCapabilityPreview();
refreshIcons();
refreshState();
})();
+1 -1
View File
@@ -566,7 +566,7 @@ async function loadGestureRuntime() {
async function loadRecognizer() {
if (state.recognizer) return;
$("#gestureSafetyText").textContent = "正在加载手势模型…";
const { FilesetResolver, GestureRecognizer } = await import(VISION_MODULE);
const { FilesetResolver, GestureRecognizer } = await import(/* @vite-ignore */ VISION_MODULE);
const fileset = await FilesetResolver.forVisionTasks(VISION_WASM);
const modelResponse = await window.CognitiveAgentNetwork.request("/api/gesture/model", {
cache: "force-cache",
+56 -19
View File
@@ -1,39 +1,76 @@
:root { color-scheme: dark; font-family: Inter, "Microsoft YaHei", system-ui, sans-serif; background: #070b12; color: #edf4ff; }
:root {
color-scheme: light;
--background: oklch(0.968 0.019 244);
--foreground: oklch(0.29 0.042 252);
--card: oklch(0.993 0.007 245);
--card-foreground: oklch(0.34 0.032 250);
--primary: oklch(0.57 0.19 258);
--primary-foreground: oklch(0.99 0.004 245);
--secondary: oklch(0.94 0.03 244);
--secondary-foreground: oklch(0.38 0.045 250);
--muted: oklch(0.955 0.017 245);
--muted-foreground: oklch(0.53 0.035 248);
--accent: oklch(0.918 0.045 249);
--destructive: oklch(0.61 0.19 24);
--border: oklch(0.85 0.04 245);
--ring: oklch(0.65 0.14 256);
--radius: 0.875rem;
font-family: Inter, "Microsoft YaHei", system-ui, sans-serif;
background: var(--background);
color: var(--foreground);
}
.dark {
color-scheme: dark;
--background: oklch(0.2 0.035 252);
--foreground: oklch(0.94 0.02 245);
--card: oklch(0.25 0.035 251);
--card-foreground: oklch(0.91 0.022 245);
--primary: oklch(0.7 0.15 252);
--primary-foreground: oklch(0.19 0.04 253);
--secondary: oklch(0.285 0.045 250);
--secondary-foreground: oklch(0.92 0.02 245);
--muted: oklch(0.275 0.032 250);
--muted-foreground: oklch(0.72 0.04 245);
--accent: oklch(0.32 0.065 250);
--destructive: oklch(0.7 0.18 24);
--border: oklch(1 0 0 / 10%);
--ring: oklch(0.68 0.13 252);
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 18% 0%, #17294b 0, transparent 35%), #070b12; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 18% 0%, color-mix(in oklch, var(--primary) 12%, transparent) 0, transparent 35%), var(--background); }
.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; }
.eyebrow { color: var(--primary); font-size: 12px; letter-spacing: .16em; font-weight: 800; margin-bottom: 8px; }
.lead, small { color: var(--muted-foreground); }
.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); }
a { color: var(--primary); text-decoration: none; border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; }
.policy, .card { color: var(--card-foreground); background: color-mix(in oklch, var(--card) 94%, transparent); border: 1px solid var(--border); border-radius: calc(var(--radius) + 8px); box-shadow: 0 20px 60px oklch(0.29 0.042 252 / 12%); }
.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; }
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--primary); box-shadow: 0 0 16px color-mix(in oklch, var(--primary) 55%, transparent); }
#clock { color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
.card { padding: 24px; margin-top: 18px; }
.active-card { border-color: #315a86; }
.active-card { border-color: var(--ring); }
.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; }
.count { min-width: 30px; height: 30px; display: grid; place-items: center; border-radius: 999px; background: var(--accent); color: var(--primary); font-weight: 800; }
.empty { padding: 26px 4px; color: var(--muted-foreground); text-align: center; }
.request-list { display: grid; gap: 12px; }
.request { padding: 16px; border-radius: 13px; border: 1px solid #26364d; background: #0b121e; }
.request { padding: 16px; border-radius: calc(var(--radius) + 3px); border: 1px solid var(--border); background: var(--muted); }
.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; }
.identity code, .identity span { color: var(--muted-foreground); 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 { color: var(--primary-foreground); background: var(--primary); border: 0; border-radius: var(--radius); 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; }
button.secondary { color: var(--secondary-foreground); background: var(--secondary); }
button.danger { color: var(--primary-foreground); background: var(--destructive); }
.lease-details { justify-content: flex-start; flex-wrap: wrap; padding: 18px; border-radius: calc(var(--radius) + 3px); background: var(--muted); border: 1px solid var(--ring); }
.lease-details .timer { margin-left: auto; color: var(--primary); font-weight: 850; font-size: 22px; font-variant-numeric: tabular-nums; }
.message { margin-top: 18px; min-height: 24px; color: var(--primary); }
@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; } }
+3 -1
View File
@@ -3,8 +3,10 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="oklch(0.57 0.19 258)">
<title>真实台架本地决策台</title>
<link rel="stylesheet" href="/hardware-control.css">
<link rel="stylesheet" href="/hardware-control.css?v=ai-blue-2">
</head>
<body>
<main class="shell">
+37 -597
View File
@@ -3,615 +3,55 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light" />
<meta name="theme-color" content="#146b65" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#17182b" id="themeColorMeta" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/app-icon-192.png" />
<title>511工程-智能座舱</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-16" />
<link rel="stylesheet" href="/console-v2.css?v=sync-layout-40" />
<link rel="stylesheet" href="/self-extension.css?v=sync-layout-26" />
<script>
(() => {
const key = "cognitive-agent.glass-theme";
let theme = "dark";
try {
const stored = localStorage.getItem(key);
if (stored === "light" || stored === "dark") theme = stored;
} catch (_error) {
// Storage can be unavailable in hardened or private browser contexts.
}
try {
const cookieTheme = document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${key}=`))
?.slice(key.length + 1);
if (cookieTheme === "light" || cookieTheme === "dark") theme = cookieTheme;
} catch (_error) {
// The default still applies when cookies are disabled.
}
document.documentElement.dataset.glassTheme = theme;
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/firmware-workbench-entry.css?v=2" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-2" defer></script>
<script src="/app.js?v=dual-seat-101" defer></script>
<script src="/app.js?v=dual-seat-148-seat-preset" defer></script>
<script src="/speech-input.js?v=sensevoice-13" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=sync-layout-41" defer></script>
<script src="/self-extension.js?v=sync-layout-26" defer></script>
<script src="/console-v2.js?v=local-first-79-cinema-status" defer></script>
<script src="/self-extension.js?v=peripheral-cards-59" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-41-unified-preview" defer></script>
<script src="/energy-monitor.js?v=energy-monitor-5-performance" defer></script>
<script src="/firmware-workbench-entry.js?v=3" defer></script>
</head>
<body class="console-v2">
<div class="v2-boot-screen" id="v2BootScreen" role="status" aria-live="polite">
<span class="v2-boot-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<strong>511工程-智能座舱</strong>
<small>正在加载车机控制与实时状态</small>
</div>
<div class="workspace">
<aside class="command-panel">
<div class="panel-heading">
<span class="section-kicker">NATURAL CABIN CONTROL</span>
<h2>说一句,就调好了</h2>
<p class="panel-intro">座椅、空调、天幕与氛围灯,一句话联动完成。</p>
</div>
<div class="mode-rail" aria-label="快捷功能模式">
<div class="mode-rail-heading">
<div><span>功能模式</span><small>点击模式即可执行,运行状态会实时反馈</small></div>
<button id="saveModeToggle" type="button"><i data-lucide="bookmark-plus"></i>根据指令创建</button>
</div>
<div class="mode-chips" aria-label="快捷功能模式" aria-live="polite">
<button type="button" data-mode-profile-id="zero_gravity" data-mode-command="进入零重力模式"><i data-lucide="orbit"></i><span>零重力</span></button>
<button type="button" data-mode-profile-id="office" data-mode-command="进入办公模式"><i data-lucide="briefcase"></i><span>办公</span></button>
<button type="button" data-mode-profile-id="nap" data-mode-command="进入小憩模式"><i data-lucide="moon-star"></i><span>小憩</span></button>
</div>
<div class="mode-rail-controls" aria-label="功能模式浏览">
<div class="mode-rail-pager" role="group" aria-label="翻阅快捷功能模式">
<button class="mode-rail-page" id="modeRailPrev" type="button" aria-label="查看上一组功能模式" title="上一组" disabled><i data-lucide="chevron-left"></i></button>
<button class="mode-rail-page" id="modeRailNext" type="button" aria-label="查看下一组功能模式" title="下一组"><i data-lucide="chevron-right"></i></button>
</div>
<button class="mode-gallery-toggle" id="modeGalleryToggle" type="button" aria-expanded="false" aria-controls="modeGallery"><i data-lucide="layout-grid"></i><span>全部模式</span><strong id="modeGalleryCount">0</strong></button>
</div>
<div class="save-mode-composer" id="saveModeComposer" hidden>
<div class="save-mode-source"><small>将根据当前指令创建</small><p id="saveModeSource">请先在指令框输入需要固化的功能组合</p></div>
<input id="saveModeName" maxlength="24" placeholder="输入模式名称,例如:通勤模式" aria-label="新功能模式名称" />
<div class="save-mode-actions">
<button id="saveModeCancel" type="button">取消</button>
<button id="saveModeConfirm" type="button">创建并添加</button>
</div>
</div>
</div>
<form id="requestForm">
<div class="field-label-row">
<label for="utterance">自然语言指令</label>
<button class="context-reset" id="newConversation" type="button" title="开始新会话" aria-label="开始新会话">
<i data-lucide="message-square-plus"></i>
</button>
</div>
<div class="command-composer">
<textarea id="utterance" name="utterance" maxlength="1000" placeholder="例如:有点热,把副驾驶调舒服些,再把天幕调暗">进入观影状态:空调24度、风量2档,天幕调暗,氛围灯换成暖橙色</textarea>
<span class="composer-hint">Enter 执行 · Shift + Enter 换行</span>
</div>
<div class="field-group">
<span class="field-label">意图解析器</span>
<div class="segmented" role="group" aria-label="意图解析器">
<button type="button" class="segment active" data-provider="openai">
<i data-lucide="sparkles"></i>GPT
</button>
<button type="button" class="segment" data-provider="local">
<i data-lucide="braces"></i>本地规则
</button>
</div>
<p class="provider-note" id="providerNote">正在读取运行时配置</p>
</div>
<div class="field-group" id="reasoningField">
<span class="field-label">模型推理强度</span>
<div class="segmented reasoning-segmented" role="group" aria-label="模型推理强度">
<button type="button" class="segment reasoning-option" data-reasoning-effort="low">
<i data-lucide="zap"></i><span><strong>极速</strong><small>简单指令,优先响应速度</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
<button type="button" class="segment reasoning-option active" data-reasoning-effort="medium">
<i data-lucide="scale"></i><span><strong>均衡</strong><small>日常控制,兼顾速度与理解</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
<button type="button" class="segment reasoning-option" data-reasoning-effort="high">
<i data-lucide="brain"></i><span><strong>深度</strong><small>复杂联动、相对动作与约束</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
</div>
<p class="provider-note" id="reasoningNote">均衡模式兼顾自然语言解析精度与响应速度</p>
</div>
<div class="field-group">
<span class="field-label">执行目标</span>
<div class="segmented execution-mode-options" role="group" aria-label="执行目标">
<button type="button" class="mode-option" data-execution-mode="dry_run">
<i data-lucide="flask-conical"></i>虚拟 CAN
</button>
<button type="button" class="mode-option active" id="hardwareMode" data-execution-mode="hardware" disabled>
<i data-lucide="radio-tower"></i>真实台架
</button>
<button type="button" class="mode-option" id="simulationMode" data-execution-mode="simulated_hardware" disabled>
<i data-lucide="bug-play"></i>模拟台架
</button>
</div>
<p class="provider-note" id="hardwareNote">正在检查 CAN 适配器与 IAR 固件配置</p>
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
</div>
<div class="field-group" id="vehicleTransportField">
<span class="field-label">车辆连接方式</span>
<div class="segmented vehicle-transport-options" role="group" aria-label="车辆连接方式">
<button type="button" class="mode-option active" data-control-transport="wired_can">
<i data-lucide="cable"></i>有线 CAN
</button>
<button type="button" class="mode-option" data-control-transport="wireless_mqtt">
<i data-lucide="wifi"></i>无线 CAN
</button>
</div>
<p class="provider-note" id="vehicleTransportNote">正在检查车辆连接</p>
</div>
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
<label class="field-label" for="simulationFault">故障注入场景</label>
<select id="simulationFault" aria-label="选择模拟硬件故障">
<option value="normal">正常反馈</option>
<option value="feedback_timeout">反馈超时</option>
<option value="adapter_disconnect">适配器断开</option>
<option value="motor_stall">电机堵转</option>
<option value="firmware_mismatch">固件不匹配</option>
<option value="partial_feedback">部分反馈</option>
</select>
<p class="provider-note">仅生成模拟 HIL 结果,不会打开物理 CAN 适配器或调用 IAR。</p>
</div>
<div class="field-group vehicle-field">
<label class="field-label" for="vehicleSelect">车型配置</label>
<div class="vehicle-select-control">
<i data-lucide="car-front"></i>
<select id="vehicleSelect" name="vehicle_id" aria-label="选择车型配置">
<option value="">加载车型配置</option>
</select>
</div>
<p class="provider-note" id="vehicleNote">正在读取座椅量程与角度标定</p>
</div>
<button class="primary-action" id="runButton" type="submit" aria-label="执行车舱控制指令">
<i data-lucide="play"></i>
<span>生成并执行</span>
</button>
</form>
</aside>
<main class="main-panel">
<section class="request-header">
<div>
<span class="section-kicker">ORCHESTRATION RUN</span>
<div class="request-title-row">
<h2 id="resultTitle">等待请求</h2>
<span class="run-status idle" id="runStatus">IDLE</span>
</div>
<p id="requestMeta">Cabin / reference_vehicle / dry-run</p>
</div>
<div class="matrix-stat">
<i data-lucide="table-properties"></i>
<div><strong id="matrixCount">10 / 57</strong><span>报文 / 信号</span></div>
</div>
</section>
<section class="cabin-band" aria-labelledby="cabinStateTitle">
<div class="section-title-row cabin-heading">
<div class="cabin-title-copy">
<span class="section-kicker">LIVE VEHICLE EXECUTORS</span>
<h3 id="cabinStateTitle">整车设备状态</h3>
<p>CH0 独立控制副驾四电机,CH1 映射主驾与车身执行器;所有状态按当前反馈源更新。</p>
</div>
<div class="cabin-channel-legend" aria-label="整车设备通道说明">
<span data-channel="ch0"><i>CH0</i>副驾座椅</span>
<span data-channel="ch1"><i>CH1</i>主驾 / 车身</span>
<span class="simulation-label" id="feedbackModeLabel">MATRIX-BACKED · SIMULATED FEEDBACK</span>
</div>
</div>
<div class="domain-grid">
<article class="domain-panel climate-panel" data-domain-panel="climate" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="fan"></i></span>
<div><strong>空调</strong><small>CMD 0x1DE · FB 0x404 · CH1</small></div>
<span class="device-state" id="climatePower">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>电源 · 温度 · 风量/风向</p>
<div class="climate-display">
<div class="zone-temperature">
<span>主驾</span><strong id="driverTemperature">--<sup>℃</sup></strong>
<small>风向<b id="driverAirDirection">-</b></small>
</div>
<div class="airflow-visual" id="airflowVisual" data-level="0" aria-label="空调风量">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</div>
<div class="zone-temperature passenger-temperature">
<span>副驾</span><strong id="passengerTemperature">--<sup>℃</sup></strong>
<small>风向<b id="passengerAirDirection">-</b></small>
</div>
</div>
<dl class="domain-readouts">
<div><dt>风量</dt><dd id="fanLevel">未知</dd></div>
<div><dt>循环</dt><dd id="cycleMode">未知</dd></div>
<div><dt>A/C</dt><dd id="acState">未知</dd></div>
<div><dt>前除霜</dt><dd id="defrostState">未知</dd></div>
</dl>
</article>
<article class="domain-panel vehicle-status-panel" data-domain-panel="vehicle_status" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="gauge"></i></span>
<div><strong>车辆状态</strong><small>FB 0x342 · 0x4EB · 0x407 · CH1</small></div>
<span class="device-state readonly-state" id="vehicleTelemetryState">只读</span>
</header>
<p class="domain-capability-line readonly-capability"><span>只读信号</span>档位 · 车速 · 四门锁状态</p>
<div class="vehicle-primary-readout">
<div><span>当前档位</span><strong id="vehicleGear">--</strong></div>
<div><span>实时车速</span><strong id="vehicleSpeed">-- km/h</strong></div>
</div>
<div class="door-state-grid" aria-label="四门锁状态">
<span data-door-state="front_left"><i></i>左前门<b id="frontLeftDoorState">未知</b></span>
<span data-door-state="front_right"><i></i>右前门<b id="frontRightDoorState">未知</b></span>
<span data-door-state="rear_left"><i></i>左后门<b id="rearLeftDoorState">未知</b></span>
<span data-door-state="rear_right"><i></i>右后门<b id="rearRightDoorState">未知</b></span>
</div>
</article>
<article class="domain-panel roof-panel" data-domain-panel="roof" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="panels-top-left"></i></span>
<div><strong>遮阳帘</strong><small>CMD 0x431 · FB 0x41A · CH1</small></div>
<span class="device-state" id="roofState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>开关 / 停止 · 位置</p>
<div class="domain-summary-visual shade-summary-visual" aria-hidden="true">
<span>闭合</span><i><b id="shadePositionMeter"></b></i><span>打开</span>
</div>
<dl class="domain-readouts">
<div><dt>遮阳帘</dt><dd id="shadePositionValue">未知</dd></div>
<div><dt>动作</dt><dd id="shadeMotionState">未知</dd></div>
<div><dt>初始化</dt><dd id="shadeInitializedState">未知</dd></div>
<div><dt>热保护</dt><dd id="shadeThermalState">未知</dd></div>
</dl>
</article>
<article class="domain-panel ambient-panel" data-domain-panel="ambient_light" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="lamp-ceiling"></i></span>
<div><strong>车内灯光</strong><small>INTERIOR / AMBIENT · CH1</small></div>
<span class="device-state" id="ambientPower">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>阅读灯 · 氛围亮度 · 色号</p>
<div class="domain-summary-visual ambient-summary-visual" aria-hidden="true">
<span data-ambient-zone="front"><i id="ambientFrontMeter"></i><b>前排</b></span>
<span data-ambient-zone="rear"><i id="ambientRearMeter"></i><b>后排</b></span>
</div>
<dl class="domain-readouts">
<div><dt>氛围灯</dt><dd id="ambientMode">未知</dd></div>
<div><dt>室内/阅读灯</dt><dd id="interiorLightPower">未知</dd></div>
<div><dt>氛围亮度</dt><dd id="ambientBrightness">- / -</dd></div>
<div><dt>氛围色号</dt><dd id="ambientColorIndex">- / -</dd></div>
</dl>
</article>
<article class="domain-panel window-panel" data-domain-panel="window" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="gallery-vertical-end"></i></span>
<div><strong>车窗</strong><small>CMD 0x431 · FB 0x388 · CH1</small></div>
<span class="device-state" id="windowState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>四窗独立 · 开/关/停 · 位置</p>
<div class="domain-summary-visual window-summary-visual" aria-hidden="true">
<span data-window-meter="front_left"><i></i><b>左前</b></span>
<span data-window-meter="front_right"><i></i><b>右前</b></span>
<span data-window-meter="rear_left"><i></i><b>左后</b></span>
<span data-window-meter="rear_right"><i></i><b>右后</b></span>
</div>
<dl class="domain-readouts">
<div><dt>左前窗</dt><dd id="frontLeftWindow">未知</dd></div>
<div><dt>右前窗</dt><dd id="frontRightWindow">未知</dd></div>
<div><dt>左后窗</dt><dd id="rearLeftWindow">未知</dd></div>
<div><dt>右后窗</dt><dd id="rearRightWindow">未知</dd></div>
</dl>
</article>
<article class="domain-panel lighting-panel" data-domain-panel="front_light" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="car-front"></i></span>
<div><strong>前大灯</strong><small>FB 0x38A · CH1</small></div>
<span class="device-state" id="frontLightState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>近/远光 · 雾灯 · 转向灯</p>
<div class="domain-summary-visual lighting-summary-visual" aria-hidden="true">
<span data-light-meter="lowBeamState"><i></i><b>近光</b></span>
<span data-light-meter="highBeamState"><i></i><b>远光</b></span>
<span data-light-meter="fogLightState"><i></i><b>雾灯</b></span>
<span data-light-meter="turnSignalState"><i></i><b>转向</b></span>
</div>
<dl class="domain-readouts">
<div><dt>近光灯</dt><dd id="lowBeamState">未知</dd></div>
<div><dt>远光灯</dt><dd id="highBeamState">未知</dd></div>
<div><dt>雾灯</dt><dd id="fogLightState">未知</dd></div>
<div><dt>转向灯</dt><dd id="turnSignalState">未知</dd></div>
</dl>
</article>
</div>
</section>
<section class="twin-band" aria-labelledby="twinTitle">
<div class="twin-heading">
<div class="twin-title-copy">
<span class="section-kicker">LIVE VEHICLE DIGITAL TWIN</span>
<h3 id="twinTitle">整车孪生界面</h3>
<div class="preset-indicator" id="presetIndicator" hidden>
<i data-lucide="bookmark-check"></i><span></span>
</div>
</div>
<div class="twin-toolbar">
<div class="twin-selector" role="tablist" aria-label="前排座椅孪生选择">
<button class="twin-seat-button active" type="button" role="tab" aria-selected="true" data-twin-seat="passenger">副驾座椅 · CH0</button>
<button class="twin-seat-button" type="button" role="tab" aria-selected="false" data-twin-seat="driver">主驾座椅 · CH1</button>
</div>
<button class="icon-button twin-reset" id="twinReset" type="button" title="复位 3D 视角" aria-label="复位 3D 视角">
<i data-lucide="rotate-3d"></i>
</button>
</div>
</div>
<div class="twin-stage" id="seatTwin" data-selected-seat="passenger">
<canvas id="seatTwinCanvas" data-vehicle-model="byd-tang-2024-reference" data-seat-layout="five-seat" data-active-seat="front-right" aria-label="通过模型动画展示 CH0 副驾座椅与 CH1 主驾座椅、空调气流、智能天幕、阅读灯、四门车窗与前大灯状态的整车数字孪生"></canvas>
<div class="twin-seat-label passenger"><i></i><span>副驾座椅</span><small>CH0 · 右前排</small></div>
<div class="twin-seat-label driver"><i></i><span>主驾座椅</span><small>CH1 · 左前排</small></div>
<div class="twin-callout-layer" aria-hidden="true">
<div class="twin-callout" data-callout="longitudinal"><span>水平位置</span><b>50%</b><small>导轨</small></div>
<div class="twin-callout" data-callout="cushion_tilt"><span>坐垫俯仰</span><b>4.5°</b><small>50%</small></div>
<div class="twin-callout" data-callout="height"><span>座椅高度</span><b>50%</b><small>CH1 高度电机</small></div>
<div class="twin-callout" data-callout="backrest_recline"><span>靠背夹角</span><b>13.6°</b><small>20% · 相对归零位</small></div>
<div class="twin-callout" data-callout="legrest_height"><span>腿托夹角</span><b>0°</b><small>0% · 相对垂直零位</small></div>
<div class="twin-callout" data-callout="legrest_extension"><span>腿托伸缩</span><b>0%</b><small>行程</small></div>
<div class="twin-callout included-angle-callout" data-callout="backrest_included_angle" hidden><span>靠背-坐垫夹角</span><b>104°</b><small>本轮指定夹角</small></div>
<div class="twin-callout included-angle-callout" data-callout="legrest_included_angle" hidden><span>坐垫-腿托夹角</span><b>95°</b><small>本轮指定夹角</small></div>
</div>
<div class="twin-orientation"><i data-lucide="move-down"></i><span>车头方向</span></div>
<aside class="twin-readout" aria-live="polite">
<div class="twin-readout-heading">
<div><strong id="twinSeatName">副驾座椅</strong><span id="twinSeatZone">CH0 · 右前排</span></div>
<span class="twin-feedback"><i></i>FEEDBACK</span>
</div>
<div class="twin-axis-list">
<div data-twin-axis="longitudinal"><span>水平位置</span><b>50%</b><i></i></div>
<div data-twin-axis="cushion_tilt"><span>坐垫俯仰</span><b>50%</b><i></i></div>
<div data-twin-axis="height"><span>座椅高度</span><b>50%</b><i></i></div>
<div data-twin-axis="backrest_recline"><span>靠背夹角</span><b>20% · 104°</b><i></i></div>
<div data-twin-axis="legrest_height"><span>腿托夹角</span><b>0% · 95°</b><i></i></div>
<div data-twin-axis="legrest_extension"><span>腿托伸缩</span><b>0%</b><i></i></div>
</div>
<div class="twin-thermal-row" aria-label="座椅热舒适状态">
<span data-twin-function="ventilation" data-level="0">
<i data-lucide="wind"></i>
<span class="twin-thermal-copy"><small>通风</small><b>关闭</b></span>
<span class="twin-level-meter" aria-hidden="true"><i></i><i></i><i></i></span>
</span>
<span data-twin-function="heating" data-level="0">
<i data-lucide="flame"></i>
<span class="twin-thermal-copy"><small>加热</small><b>关闭</b></span>
<span class="twin-level-meter" aria-hidden="true"><i></i><i></i><i></i></span>
</span>
</div>
</aside>
<div class="twin-fallback" id="twinFallback"><i data-lucide="loader-circle"></i><span>正在加载整车参考模型</span></div>
</div>
</section>
<section class="state-band">
<div class="seat-state-section">
<div class="section-title-row">
<div><span class="section-kicker">ACTUATOR STATE</span><h3>前排座椅状态</h3></div>
<span class="simulation-label" id="seatFeedbackModeLabel">CH0 / CH1 FEEDBACK</span>
</div>
<div class="seat-grid">
<article class="seat-card" data-seat="passenger">
<div class="seat-identity"><i data-lucide="armchair"></i><div><strong>副驾座椅</strong><span>右前排</span></div></div>
<span class="seat-channel-badge" data-seat-feedback="passenger">CH0 · 等待反馈</span>
<div class="dynamic-mode-state" data-dynamic-mode>
<i data-lucide="waves"></i><span>动态模式</span><b>未运行</b>
</div>
<div class="motion-state-grid" aria-live="polite"></div>
</article>
<article class="seat-card" data-seat="driver">
<div class="seat-identity"><i data-lucide="armchair"></i><div><strong>主驾座椅</strong><span>左前排</span></div></div>
<span class="seat-channel-badge" data-seat-feedback="driver">CH1 · 等待反馈</span>
<div class="dynamic-mode-state" data-dynamic-mode>
<i data-lucide="radio-tower"></i><span>车端 CAN FD</span><b>只读同步</b>
</div>
<div class="thermal-states">
<span data-function="ventilation"><i data-lucide="wind"></i>通风 <b>--</b></span>
<span data-function="heating"><i data-lucide="flame"></i>加热 <b>--</b></span>
<span data-function="massage"><i data-lucide="waves"></i>按摩 <b>--</b></span>
</div>
<div class="motion-state-grid" aria-live="polite"></div>
</article>
</div>
</div>
<div class="coverage-section">
<span class="section-kicker">MATRIX COVERAGE</span>
<h3>当前控制边界</h3>
<dl>
<div><dt>空调 18–32℃ / 0–7 档</dt><dd class="available">可编译</dd></div>
<div><dt>天幕 / 遮阳帘 0–100%</dt><dd class="available">可编译</dd></div>
<div><dt>氛围灯 RGB / 亮度</dt><dd class="available">可编译</dd></div>
<div><dt>副驾座椅统一执行器</dt><dd class="simulated">t2g_primary_seat</dd></div>
<div><dt>主驾座椅车端执行器</dt><dd class="simulated">CH1 · 左前排</dd></div>
<div><dt>真实台架 CAN 下发</dt><dd class="blocked" id="hardwareCoverage">未配置</dd></div>
</dl>
</div>
</section>
<section class="detail-section result-workspace" aria-labelledby="resultWorkspaceTitle">
<header class="result-workspace-heading">
<div><span class="section-kicker">ORCHESTRATION WORKSPACE</span><h3 id="resultWorkspaceTitle">编排结果工作台</h3><p>四类结果在同一页面连续呈现,也可以通过下方入口快速定位。</p></div>
<span class="result-workspace-badge"><i data-lucide="route"></i>REQUEST → CONFIG → CAN → RESULT</span>
</header>
<nav class="tabs result-nav" aria-label="结果快速导航">
<button class="tab active" type="button" data-tab="trace"><span class="tab-index">01</span><i data-lucide="list-tree"></i><span class="tab-copy"><strong>决策轨迹</strong><small id="traceNavMeta">等待请求</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="config"><span class="tab-index">02</span><i data-lucide="layout-grid"></i><span class="tab-copy"><strong>配置卡片</strong><small id="configNavMeta">等待生成</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="plan"><span class="tab-index">03</span><i data-lucide="binary"></i><span class="tab-copy"><strong>CAN 计划</strong><small id="planNavMeta">等待编译</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="result"><span class="tab-index">04</span><i data-lucide="circle-check-big"></i><span class="tab-copy"><strong>执行结果</strong><small id="resultNavMeta">尚未执行</small></span><i data-lucide="arrow-down"></i></button>
</nav>
<div class="tab-panel active" id="panel-trace" role="tabpanel">
<div class="workspace-panel-title"><div><span class="section-kicker">01 · DECISION TRACE</span><h3>决策轨迹与校验</h3><p>从自然语言识别到领域路由,集中查看每一步决策依据。</p></div><span class="workspace-panel-state" id="tracePanelState">IDLE</span></div>
<div class="trace-layout">
<div>
<div class="detail-heading"><h3>可审计决策摘要</h3><span id="providerUsed">--</span></div>
<ol class="decision-list" id="decisionList"><li>提交请求后显示识别目标、能力选择和约束来源。</li></ol>
</div>
<div>
<div class="detail-heading"><h3>阶段事件</h3><span id="traceDuration">0 ms</span></div>
<div class="trace-list" id="traceList"><div class="empty-line">暂无执行事件</div></div>
</div>
</div>
<div class="validation-block">
<div class="detail-heading"><h3>校验结果</h3><span id="validationCount">0 条</span></div>
<div id="validationList" class="validation-list"><div class="empty-line">等待配置生成</div></div>
</div>
</div>
<div class="tab-panel config-panel" id="panel-config" role="tabpanel">
<div class="config-panel-heading">
<div><span class="section-kicker">02 · INSTRUCTION → HARDWARE</span><h3>指令配置与硬件映射</h3><p>每张卡片对应一个控制对象,依次展示目标参数和最终硬件计划。</p></div>
<div class="config-actions"><button class="config-action" type="button" data-copy="config"><i data-lucide="copy"></i>复制 YAML</button><button class="config-action" type="button" data-download="config"><i data-lucide="download"></i>下载配置</button></div>
</div>
<div class="config-card-grid" id="configCards">
<div class="config-empty"><i data-lucide="panel-top-dashed"></i><strong>等待生成配置</strong><span>提交车舱控制指令后,这里会显示控制对象、目标参数和硬件信号。</span></div>
</div>
<details class="config-source-details">
<summary><span><i data-lucide="braces"></i>查看原始配置</span><small id="configFileName">functions/seat.yaml</small></summary>
<pre id="configCode"><code># 等待生成 FunctionConfig</code></pre>
</details>
</div>
<div class="tab-panel plan-panel" id="panel-plan" role="tabpanel">
<div class="workspace-panel-title plan-panel-heading"><div><span class="section-kicker">03 · CAN COMMAND PLAN</span><h3>CAN 编译计划</h3><p>快速核对帧数量、首帧载荷、反馈帧和执行模式。</p></div><button class="config-action" type="button" data-copy="plan"><i data-lucide="copy"></i>复制计划</button></div>
<div class="frame-summary" id="frameSummary"><span>Frame ID</span><b>--</b><span>Payload</span><code>--</code><span>Mode</span><b>DRY-RUN</b></div>
<details class="config-source-details plan-source-details">
<summary><span><i data-lucide="binary"></i>查看原始 CAN 计划</span><small id="planFileName">command-plans/seat.yaml</small></summary>
<pre id="planCode"><code># 等待 CAN 编译</code></pre>
</details>
</div>
<div class="tab-panel result-panel" id="panel-result" role="tabpanel">
<div class="workspace-panel-title"><div><span class="section-kicker">04 · EXECUTION RESULT</span><h3>执行结果与硬件回读</h3><p>汇总每个领域的执行状态、CAN 回读和失败原因。</p></div><span class="workspace-panel-state" id="resultPanelState">IDLE</span></div>
<div class="execution-summary" id="executionSummary">
<i data-lucide="circle-dashed"></i><div><strong>尚未执行</strong><span>运行结果将在此处显示</span></div>
</div>
<div class="result-list" id="resultList"></div>
</div>
</section>
</main>
</div>
<aside class="gesture-drawer" id="gestureDrawer" aria-hidden="true" aria-labelledby="gestureDrawerTitle">
<header class="gesture-drawer-header">
<div>
<span class="section-kicker">VISION CONTROL</span>
<h2 id="gestureDrawerTitle">手势控制</h2>
<p>本机识别,动作通过当前编排链路执行。</p>
</div>
<button class="gesture-close" id="gestureCloseButton" type="button" aria-label="关闭手势控制"><i data-lucide="x"></i></button>
</header>
<div class="gesture-status-strip">
<span class="gesture-state-pill idle" id="gestureStatePill"><i></i><b>未启动</b></span>
<span id="gestureSafetyText">摄像头尚未开启</span>
</div>
<div class="gesture-camera-stage">
<video id="gestureVideo" playsinline muted></video>
<canvas id="gestureCanvas"></canvas>
<div class="gesture-camera-empty" id="gestureCameraEmpty">
<span><i data-lucide="scan-face"></i></span>
<strong>用手势控制副驾驶座椅</strong>
<p>画面只在本机处理,不上传视频。</p>
</div>
<div class="gesture-live-badge" id="gestureLiveBadge" hidden><i></i>LIVE</div>
</div>
<div class="gesture-now">
<div><span>当前手势</span><strong id="gestureDetected">—</strong></div>
<i data-lucide="arrow-right"></i>
<div><span>期望动作</span><strong id="gestureExpected">等待启动</strong></div>
</div>
<div class="gesture-progress"><i id="gestureProgressBar"></i></div>
<div class="gesture-actions">
<button class="gesture-primary" id="gestureStartButton" type="button"><i data-lucide="camera"></i><span>开启摄像头</span></button>
<button id="gestureStopButton" type="button" disabled><i data-lucide="octagon"></i>停止</button>
</div>
<section class="gesture-guide-card">
<div class="gesture-card-heading"><div><span>手势速查</span><small>张掌激活 · 握拳只停止</small></div><button id="gestureCustomizeButton" type="button"><i data-lucide="sliders-horizontal"></i>自定义</button></div>
<div class="gesture-map-grid" id="gestureMapGrid"></div>
<div class="gesture-customize" id="gestureCustomizePanel" hidden>
<div><span>识别到</span><strong id="gestureCustomizeToken">请先摆出手势</strong></div>
<select id="gestureActionSelect" aria-label="选择座椅动作"></select>
<button id="gestureBindButton" type="button">绑定当前手势</button>
<button id="gestureResetMapping" type="button">恢复默认</button>
</div>
</section>
<footer class="gesture-footnote"><i data-lucide="shield-check"></i><span>换动作无需重新张掌;连续 3 秒无有效动作后自动锁止。</span></footer>
</aside>
<div class="mode-gallery-backdrop" id="modeGalleryBackdrop" hidden>
<section class="mode-gallery" id="modeGallery" role="dialog" aria-modal="true" aria-labelledby="modeGalleryTitle" aria-describedby="modeGalleryDescription">
<header class="mode-gallery-header">
<div>
<span class="mode-gallery-kicker">SCENE LIBRARY</span>
<h2 id="modeGalleryTitle">全部功能模式</h2>
<p id="modeGalleryDescription">按场景集中展示所有预设及用户创建模式,点击任一模式即可执行。</p>
</div>
<button class="mode-gallery-close" id="modeGalleryClose" type="button" aria-label="关闭全部功能模式"><i data-lucide="x"></i></button>
</header>
<div class="mode-gallery-summary"><span><i data-lucide="layers-3"></i>可用模式</span><strong id="modeGallerySummaryCount">0</strong></div>
<div class="mode-gallery-groups" id="modeGalleryGroups" aria-live="polite"></div>
<footer class="mode-gallery-footer"><i data-lucide="info"></i><span>执行状态与主界面同步:蓝色执行中、绿色已完成、红色执行失败。</span></footer>
</section>
</div>
<div class="mode-editor-backdrop" id="modeEditorBackdrop" hidden>
<section class="mode-editor" role="dialog" aria-modal="true" aria-labelledby="modeEditorTitle" aria-describedby="modeEditorDescription">
<div class="mode-editor-setup">
<header class="mode-editor-header">
<div>
<span>MODE WORKFLOW</span>
<h2 id="modeEditorTitle">编辑功能模式</h2>
<p id="modeEditorDescription">执行后生成 CAN 控制指令流;完成后永久保存到当前模式卡片。</p>
</div>
<button id="modeEditorClose" type="button" aria-label="关闭模式编辑器"><i data-lucide="x"></i></button>
</header>
<div class="mode-editor-inputs">
<label class="mode-editor-field mode-editor-name-field" for="modeEditorName">
<span>模式名称</span>
<input id="modeEditorName" maxlength="64" autocomplete="off" placeholder="输入模式名称" />
</label>
<label class="mode-editor-field mode-editor-instruction-field" for="modeEditorInstruction">
<span>自然语言指令</span>
<textarea id="modeEditorInstruction" maxlength="1000" rows="3" placeholder="例如:空调24度、风量2档,打开遮阳帘"></textarea>
</label>
</div>
</div>
<section class="mode-editor-flow" aria-live="polite">
<header>
<span><i data-lucide="workflow"></i>CAN 控制指令流</span>
<div class="mode-editor-flow-tools">
<b id="modeEditorFlowCount">尚未生成</b>
<button id="modeEditorFlowExpand" type="button" aria-expanded="false" aria-label="展开 CAN 控制指令区域" title="展开 CAN 控制指令区域">
<i data-lucide="maximize-2"></i>
</button>
</div>
</header>
<div class="mode-editor-flow-preview empty" id="modeEditorFlowPreview">
<p>点击“执行”后,这里会显示由后端生成的 CAN 控制指令流。</p>
</div>
</section>
<p class="mode-editor-status" id="modeEditorStatus">修改自然语言后需要重新执行,才能点击完成永久保存。</p>
<footer class="mode-editor-actions">
<button id="modeEditorCancel" type="button">取消</button>
<button class="mode-editor-execute" id="modeEditorExecute" type="button"><i data-lucide="play"></i><span>执行</span></button>
<button class="mode-editor-complete" id="modeEditorComplete" type="button" disabled><i data-lucide="save"></i><span>完成</span></button>
</footer>
</section>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div id="app" aria-busy="true"></div>
<noscript>需要启用 JavaScript 才能使用智能座舱控制台。</noscript>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+73 -29
View File
@@ -1,10 +1,52 @@
:root {
color: #17232b;
background: #e8efed;
color-scheme: light;
--background: oklch(0.968 0.019 244);
--foreground: oklch(0.29 0.042 252);
--card: oklch(0.993 0.007 245);
--card-foreground: oklch(0.34 0.032 250);
--popover: oklch(0.998 0.004 245);
--popover-foreground: oklch(0.29 0.042 252);
--primary: oklch(0.57 0.19 258);
--primary-foreground: oklch(0.99 0.004 245);
--secondary: oklch(0.94 0.03 244);
--secondary-foreground: oklch(0.38 0.045 250);
--muted: oklch(0.955 0.017 245);
--muted-foreground: oklch(0.53 0.035 248);
--accent: oklch(0.918 0.045 249);
--accent-foreground: oklch(0.38 0.1 258);
--destructive: oklch(0.61 0.19 24);
--border: oklch(0.85 0.04 245);
--input: oklch(0.82 0.045 246);
--ring: oklch(0.65 0.14 256);
--radius: 0.875rem;
color: var(--foreground);
background: var(--background);
font-family: Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
letter-spacing: 0;
}
.dark {
color-scheme: dark;
--background: oklch(0.2 0.035 252);
--foreground: oklch(0.94 0.02 245);
--card: oklch(0.25 0.035 251);
--card-foreground: oklch(0.91 0.022 245);
--popover: oklch(0.235 0.038 252);
--popover-foreground: oklch(0.94 0.02 245);
--primary: oklch(0.7 0.15 252);
--primary-foreground: oklch(0.19 0.04 253);
--secondary: oklch(0.285 0.045 250);
--secondary-foreground: oklch(0.92 0.02 245);
--muted: oklch(0.275 0.032 250);
--muted-foreground: oklch(0.72 0.04 245);
--accent: oklch(0.32 0.065 250);
--accent-foreground: oklch(0.91 0.03 244);
--destructive: oklch(0.7 0.18 24);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.68 0.13 252);
}
* {
box-sizing: border-box;
}
@@ -14,9 +56,9 @@ body {
min-height: 100vh;
margin: 0;
background:
linear-gradient(rgba(217, 229, 225, 0.7) 1px, transparent 1px),
linear-gradient(90deg, rgba(217, 229, 225, 0.7) 1px, transparent 1px),
#eef3f1;
linear-gradient(color-mix(in oklch, var(--border) 70%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in oklch, var(--border) 70%, transparent) 1px, transparent 1px),
linear-gradient(145deg, var(--background), var(--secondary));
background-size: 34px 34px;
}
@@ -30,10 +72,12 @@ body {
.login-panel {
width: min(420px, 100%);
padding: 28px;
background: #fff;
border: 1px solid #cbd7d3;
border-top: 4px solid #08766e;
box-shadow: 0 18px 50px rgba(24, 42, 48, 0.12);
color: var(--card-foreground);
background: var(--card);
border: 1px solid var(--border);
border-top: 4px solid var(--primary);
border-radius: var(--radius);
box-shadow: 0 18px 50px oklch(0.29 0.042 252 / 12%);
}
.login-brand {
@@ -41,7 +85,7 @@ body {
align-items: center;
gap: 12px;
padding-bottom: 24px;
border-bottom: 1px solid #d9e1de;
border-bottom: 1px solid var(--border);
}
.login-brand > span {
@@ -49,8 +93,8 @@ body {
height: 42px;
display: grid;
place-items: center;
color: #fff;
background: #08766e;
color: var(--primary-foreground);
background: var(--primary);
border-radius: 5px;
font-size: 13px;
font-weight: 800;
@@ -67,7 +111,7 @@ body {
.login-brand small {
margin-top: 4px;
color: #67747b;
color: var(--muted-foreground);
font: 8px "SFMono-Regular", Consolas, monospace;
}
@@ -77,7 +121,7 @@ body {
.login-heading p {
margin: 0 0 7px;
color: #08766e;
color: var(--primary);
font: 750 9px "SFMono-Regular", Consolas, monospace;
}
@@ -92,7 +136,7 @@ form {
label {
margin: 0 0 7px;
color: #4f5f66;
color: var(--muted-foreground);
font-size: 11px;
font-weight: 700;
}
@@ -102,32 +146,32 @@ input {
height: 44px;
margin-bottom: 18px;
padding: 0 12px;
color: #17232b;
background: #f7f9f8;
border: 1px solid #bbc8c4;
border-radius: 4px;
color: var(--foreground);
background: var(--background);
border: 1px solid var(--input);
border-radius: calc(var(--radius) - 2px);
font: inherit;
letter-spacing: 0;
}
input:focus {
outline: 3px solid rgba(50, 105, 168, 0.16);
border-color: #3269a8;
outline: 3px solid color-mix(in oklch, var(--ring) 24%, transparent);
border-color: var(--ring);
}
.login-message {
min-height: 17px;
margin: -4px 0 10px;
color: #bd3f49;
color: var(--destructive);
font-size: 11px;
}
button {
height: 44px;
color: #fff;
background: #08766e;
color: var(--primary-foreground);
background: var(--primary);
border: 0;
border-radius: 4px;
border-radius: calc(var(--radius) - 2px);
font: inherit;
font-weight: 750;
letter-spacing: 0;
@@ -135,7 +179,7 @@ button {
}
button:hover {
background: #075c57;
background: color-mix(in oklch, var(--primary) 86%, var(--foreground));
}
button:disabled {
@@ -148,7 +192,7 @@ footer {
align-items: center;
gap: 8px;
margin-top: 26px;
color: #67747b;
color: var(--muted-foreground);
font: 8px "SFMono-Regular", Consolas, monospace;
}
@@ -156,8 +200,8 @@ footer i {
width: 7px;
height: 7px;
border-radius: 50%;
background: #08766e;
box-shadow: 0 0 0 4px rgba(8, 118, 110, 0.12);
background: var(--primary);
box-shadow: 0 0 0 4px color-mix(in oklch, var(--primary) 12%, transparent);
}
@media (max-width: 460px) {
+3 -2
View File
@@ -3,9 +3,10 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="oklch(0.57 0.19 258)" />
<title>登录 · Cognitive Agent</title>
<link rel="stylesheet" href="/login.css" />
<link rel="stylesheet" href="/login.css?v=ai-blue-2" />
<script src="/network.js?v=android-pad-2" defer></script>
<script src="/login.js" defer></script>
</head>
+2 -2
View File
@@ -7,8 +7,8 @@
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#eef3f1",
"theme_color": "#146b65",
"background_color": "oklch(0.968 0.019 244)",
"theme_color": "oklch(0.57 0.19 258)",
"icons": [
{
"src": "/icons/app-icon-192.png",
+1394
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@n7/cognitive-agent-web-console",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"vue": "3.5.21"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.1",
"vite": "7.3.6"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+24 -7
View File
@@ -1,24 +1,41 @@
const CACHE_VERSION = "cognitive-agent-pad-v202";
const CACHE_VERSION = "cognitive-agent-pad-v338-unified-preview";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
"/",
"/index.html",
"/styles.css?v=dual-seat-16",
"/styles.css?v=dual-seat-24-dialog-size",
"/network.js?v=android-pad-2",
"/app.js?v=dual-seat-101",
"/app.js?v=dual-seat-148-seat-preset",
"/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",
"/console-v2.css?v=delivery-state-69",
"/console-v2.js?v=local-first-79-cinema-status",
"/self-extension.css?v=peripheral-cards-55",
"/self-extension.js?v=peripheral-cards-59",
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5",
"/assistant-chat.css?v=codex-assistant-31-unified-preview",
"/assistant-chat.js?v=codex-assistant-41-unified-preview",
"/energy-monitor.css?v=energy-monitor-5-dialog-size",
"/energy-monitor.js?v=energy-monitor-5-performance",
"/ui-consistency.css?v=spatial-ui-8",
"/codepen-glass-theme.css?v=creative-cloud-6-performance",
"/vue-ui.js?v=vue-presentation-5-draft-footer",
"/firmware-workbench-entry.css?v=2",
"/firmware-workbench-entry.js?v=3",
"/firmware-workbench.html",
"/firmware-workbench-theme.js?v=1",
"/firmware-workbench.css?v=8",
"/firmware-workbench.js?v=5",
"/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3",
"/manifest.webmanifest",
"/icons/app-icon-192.png",
"/icons/app-icon-512.png",
"/vendor/lucide/0.468.0/lucide.min.js",
"/vendor/vue/3.5.21/vue.runtime.global.prod.js",
"/vendor/three/0.147.0/three.min.js",
"/vendor/three/0.147.0/fflate.min.js",
"/vendor/three/0.147.0/FBXLoader.js",
+29
View File
@@ -0,0 +1,29 @@
class SpeechCaptureProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.frameSize = 2048;
this.frame = new Float32Array(this.frameSize);
this.offset = 0;
}
process(inputs) {
const input = inputs[0]?.[0];
if (!input) return true;
let inputOffset = 0;
while (inputOffset < input.length) {
const available = Math.min(this.frameSize - this.offset, input.length - inputOffset);
this.frame.set(input.subarray(inputOffset, inputOffset + available), this.offset);
this.offset += available;
inputOffset += available;
if (this.offset === this.frameSize) {
const ready = this.frame;
this.port.postMessage(ready, [ready.buffer]);
this.frame = new Float32Array(this.frameSize);
this.offset = 0;
}
}
return true;
}
}
registerProcessor("speech-capture-processor", SpeechCaptureProcessor);
+298
View File
@@ -0,0 +1,298 @@
.speech-visually-hidden {
width: 1px;
height: 1px;
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
/* Keep all composer actions on one calm, optically aligned rail. */
.console-v2 .v2-request-form .speech-dictation {
width: 36px;
height: 36px;
min-height: 36px;
position: absolute;
top: 50%;
right: 56px;
bottom: auto;
z-index: 13;
display: grid;
place-items: center;
margin: 0;
padding: 0;
color: var(--foreground);
background: transparent;
border: 0;
border-radius: 50%;
box-shadow: none;
cursor: pointer;
transform: translateY(-50%);
transition: color 160ms ease, background 160ms ease, box-shadow 160ms ease,
transform 160ms ease;
}
.console-v2 .v2-request-form .speech-dictation:hover:not(:disabled),
.console-v2 .v2-request-form .speech-dictation:focus-visible {
color: var(--primary);
background: color-mix(in oklch, var(--primary) 9%, var(--background));
box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 8%, transparent);
transform: translateY(-50%) scale(1.05);
}
.console-v2 .v2-request-form .speech-dictation:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.console-v2 .v2-request-form .speech-dictation:disabled {
color: var(--muted-foreground);
cursor: not-allowed;
opacity: 0.56;
}
.console-v2 .v2-request-form .speech-dictation .speech-mic-icon {
width: 18px;
height: 18px;
stroke-width: 2;
}
.console-v2 .v2-request-form .speech-waveform {
height: 16px;
display: none;
align-items: center;
justify-content: center;
gap: 2px;
}
.console-v2 .v2-request-form .speech-waveform i {
width: 2px;
height: 5px;
display: block;
background: currentColor;
border-radius: 999px;
}
.console-v2 .v2-request-form .speech-waveform i:nth-child(2),
.console-v2 .v2-request-form .speech-waveform i:nth-child(4) {
height: 10px;
}
.console-v2 .v2-request-form .speech-waveform i:nth-child(3) {
height: 15px;
}
.console-v2 .v2-request-form .speech-dictation.is-listening,
.console-v2 .v2-request-form .speech-dictation.is-listening:hover:not(:disabled),
.console-v2 .v2-request-form .speech-dictation.is-listening:focus-visible {
color: var(--primary-foreground);
background: var(--primary);
box-shadow: 0 0 0 5px color-mix(in oklch, var(--primary) 9%, transparent), 0 7px 18px color-mix(in oklch, var(--primary) 20%, transparent);
}
.console-v2 .v2-request-form .speech-dictation.is-armed,
.console-v2 .v2-request-form .speech-dictation.is-armed:hover:not(:disabled),
.console-v2 .v2-request-form .speech-dictation.is-armed:focus-visible {
color: var(--primary);
background: color-mix(in oklch, var(--primary) 10%, var(--background));
box-shadow: 0 0 0 4px color-mix(in oklch, var(--primary) 8%, transparent);
}
.console-v2 .v2-request-form .speech-dictation.is-armed::after {
width: 7px;
height: 7px;
position: absolute;
top: 4px;
right: 3px;
content: "";
background: var(--primary);
border: 2px solid var(--background);
border-radius: 50%;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-mic-icon,
.console-v2 .v2-request-form .speech-dictation.is-processing .speech-mic-icon {
display: none;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-waveform,
.console-v2 .v2-request-form .speech-dictation.is-processing .speech-waveform {
display: flex;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-waveform i {
animation: speech-wave 760ms ease-in-out infinite alternate;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-waveform i:nth-child(2) {
animation-delay: -420ms;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-waveform i:nth-child(3) {
animation-delay: -180ms;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-waveform i:nth-child(4) {
animation-delay: -560ms;
}
.console-v2 .v2-request-form .speech-dictation.is-processing {
color: var(--primary);
background: color-mix(in oklch, var(--primary) 10%, var(--background));
}
.console-v2 .v2-request-form .speech-dictation.is-processing .speech-waveform {
animation: speech-processing-breathe 900ms ease-in-out infinite alternate;
}
.console-v2 .v2-request-form .speech-status {
min-height: 16px;
position: absolute;
right: 140px;
bottom: 10px;
left: 194px;
z-index: 12;
overflow: hidden;
color: var(--muted-foreground);
font-size: 10px;
font-weight: 650;
line-height: 16px;
letter-spacing: 0.01em;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
}
.console-v2 .v2-request-form .command-composer[data-speech-state="listening"] {
border-color: color-mix(in oklch, var(--primary) 36%, transparent);
box-shadow: 0 12px 35px color-mix(in oklch, var(--primary) 10%, transparent), inset 0 0 0 1px color-mix(in oklch, var(--primary) 8%, transparent);
}
.console-v2 .v2-request-form .command-composer[data-speech-state="armed"],
.console-v2 .v2-request-form .command-composer[data-speech-state="capturing"] {
border-color: color-mix(in oklch, var(--primary) 36%, transparent);
box-shadow: 0 12px 35px color-mix(in oklch, var(--primary) 10%, transparent), inset 0 0 0 1px color-mix(in oklch, var(--primary) 8%, transparent);
}
.console-v2 .v2-request-form .command-composer[data-speech-state="listening"] .speech-status {
color: var(--foreground);
}
.console-v2 .v2-request-form .command-composer[data-speech-state="armed"] .speech-status,
.console-v2 .v2-request-form .command-composer[data-speech-state="capturing"] .speech-status {
color: var(--foreground);
}
.console-v2 .v2-request-form .command-composer[data-speech-state="processing"] .speech-status {
color: color-mix(in oklch, var(--primary) 72%, var(--foreground));
}
.console-v2 .v2-request-form .command-composer[data-speech-state="warming"] .speech-status,
.console-v2 .v2-request-form .command-composer[data-speech-state="arming"] .speech-status,
.console-v2 .v2-request-form .command-composer[data-speech-state="finalizing"] .speech-status {
color: color-mix(in oklch, var(--primary) 72%, var(--foreground));
}
.console-v2 .v2-request-form .command-composer[data-speech-state="success"] .speech-status {
color: var(--primary);
}
.console-v2 .v2-request-form #utterance[data-speech-uncertain="true"] {
background: linear-gradient(90deg, color-mix(in oklch, var(--chart-1) 10%, transparent), transparent 42%);
box-shadow: inset 3px 0 0 color-mix(in oklch, var(--chart-1) 58%, transparent);
}
.console-v2 .v2-request-form #utterance[data-speech-stage="partial"] {
color: color-mix(in oklch, var(--foreground) 82%, var(--muted-foreground));
}
.console-v2 .v2-request-form .command-composer[data-speech-state="error"] .speech-status {
color: var(--destructive);
}
.console-v2 .v2-request-form .command-composer:not([data-speech-state="idle"]) .composer-hint {
opacity: 0;
}
.console-v2 .v2-request-form #utterance,
.console-v2.v2-interactive-sync .v2-request-form #utterance,
.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form #utterance {
padding-right: 142px;
}
.console-v2 .v2-request-form .context-reset,
.console-v2.v2-interactive-sync .v2-request-form .context-reset,
.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .context-reset {
width: 30px;
height: 30px;
top: 50%;
right: 98px;
bottom: auto;
color: var(--muted-foreground);
border-radius: 50%;
transform: translateY(-50%);
}
.console-v2 .v2-request-form .context-reset:hover,
.console-v2 .v2-request-form .context-reset:focus-visible {
color: var(--primary);
background: color-mix(in oklch, var(--primary) 8%, var(--background));
}
.console-v2 .v2-request-form .composer-hint,
.console-v2.v2-interactive-sync .v2-request-form .composer-hint,
.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .composer-hint {
right: 140px;
}
@keyframes speech-wave {
from { transform: scaleY(0.42); }
to { transform: scaleY(1); }
}
@keyframes speech-processing-breathe {
from { opacity: 0.42; transform: scaleX(0.78); }
to { opacity: 1; transform: scaleX(1); }
}
@media (max-width: 720px) {
.console-v2 .v2-request-form .speech-status {
top: 8px;
right: 140px;
bottom: auto;
left: 14px;
font-size: 9px;
}
.console-v2 .v2-request-form .command-composer:not([data-speech-state="idle"]) #utterance {
padding-top: 28px;
}
}
@media (max-width: 460px) {
.console-v2 .v2-request-form #utterance,
.console-v2.v2-interactive-sync .v2-request-form #utterance,
.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form #utterance {
padding-right: 132px;
}
.console-v2 .v2-request-form .speech-dictation {
right: 52px;
}
.console-v2 .v2-request-form .context-reset,
.console-v2.v2-interactive-sync .v2-request-form .context-reset,
.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .context-reset {
right: 92px;
}
}
@media (prefers-reduced-motion: reduce) {
.console-v2 .v2-request-form .speech-dictation,
.console-v2 .v2-request-form .speech-waveform,
.console-v2 .v2-request-form .speech-waveform i {
animation: none !important;
transition: none;
}
}
File diff suppressed because it is too large Load Diff
+584
View File
@@ -0,0 +1,584 @@
<script setup>
defineOptions({ name: "N7ConsoleApp" });
</script>
<template>
<div class="v2-boot-screen" id="v2BootScreen" role="status" aria-live="polite">
<span class="v2-boot-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<strong>511工程-智能座舱</strong>
<small>正在加载车机控制与实时状态</small>
</div>
<div class="workspace">
<aside class="command-panel">
<div class="panel-heading">
<h2>说一句,就调好了</h2>
</div>
<div class="mode-rail" aria-label="功能模式">
<div class="mode-rail-bar">
<div class="mode-rail-identity">
<span class="mode-rail-mark" aria-hidden="true"><i data-lucide="sparkles"></i></span>
<span><strong class="mode-rail-title">功能模式</strong><small>预设与我的模式</small></span>
</div>
<div class="mode-rail-content" id="modeRailContent" role="region" aria-label="功能模式列表">
<div class="mode-chips" tabindex="0" aria-label="快捷功能模式,可横向滚动" aria-live="polite">
<button type="button" data-mode-profile-id="zero_gravity" data-mode-origin="preset" data-mode-command="进入零重力模式"><i data-lucide="orbit"></i><span>零重力</span></button>
<button type="button" data-mode-profile-id="office" data-mode-origin="preset" data-mode-command="进入办公模式"><i data-lucide="briefcase"></i><span>办公</span></button>
<button type="button" data-mode-profile-id="nap" data-mode-origin="preset" data-mode-command="进入小憩模式"><i data-lucide="moon-star"></i><span>小憩</span></button>
</div>
</div>
<div class="mode-rail-actions">
<button class="mode-gallery-toggle" id="modeGalleryToggle" type="button" aria-expanded="false" aria-controls="modeGallery"><i data-lucide="settings-2" data-icon="inline-start"></i><span>管理</span><strong id="modeGalleryCount">0</strong></button>
<button class="mode-create-toggle" id="saveModeToggle" type="button"><i data-lucide="sparkles" data-icon="inline-start"></i><span>AI 新建</span></button>
<button class="mode-rail-toggle" id="modeRailToggle" type="button" aria-expanded="true" aria-controls="modeRailContent" aria-label="收起功能模式" title="收起功能模式">
<span class="visually-hidden" id="modeRailSelection">静态模式</span><i data-lucide="chevron-up"></i>
</button>
</div>
</div>
</div>
<form id="requestForm" autocomplete="off">
<section class="assistant-transcript" id="assistantTranscript" role="log" aria-live="polite" aria-label="AI助手对话">
<div class="assistant-messages" id="assistantMessages">
<article class="assistant-message assistant-message-assistant">
<span class="assistant-avatar" aria-hidden="true"><i data-lucide="waypoints"></i></span>
<div class="assistant-message-body">
<div class="assistant-message-content">你好,我是 AI助手。你可以向我提问,也可以直接交代需要完成的工作。</div>
</div>
</article>
</div>
</section>
<div class="field-label-row">
<label for="utterance">向 AI助手提问或交代工作</label>
<button class="context-reset" id="newConversation" type="button" title="开始新会话" aria-label="开始新会话">
<i data-lucide="message-square-plus"></i>
</button>
</div>
<div class="command-composer">
<textarea id="utterance" name="utterance" maxlength="4000" autocomplete="off" placeholder="向 AI助手提问或交代工作"></textarea>
<button class="speech-dictation" id="speechDictationButton" type="button" aria-label="开始语音输入" aria-pressed="false" title="开始语音输入">
<i class="speech-mic-icon" data-lucide="mic" aria-hidden="true"></i>
<span class="speech-waveform" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span>
<span class="speech-visually-hidden">开始语音输入</span>
</button>
<span class="speech-status" id="speechDictationStatus" role="status" aria-live="polite"></span>
<span class="composer-hint">Enter 发送 · Shift + Enter 换行</span>
</div>
<div class="field-group">
<span class="field-label">意图解析器</span>
<div class="segmented" role="group" aria-label="意图解析器">
<button type="button" class="segment active" data-provider="openai">
<i data-lucide="sparkles"></i>GPT
</button>
<button type="button" class="segment" data-provider="local">
<i data-lucide="braces"></i>本地规则
</button>
</div>
<p class="provider-note" id="providerNote">正在读取运行时配置</p>
</div>
<div class="field-group" id="reasoningField">
<span class="field-label">模型推理强度</span>
<div class="segmented reasoning-segmented" role="group" aria-label="模型推理强度">
<button type="button" class="segment reasoning-option" data-reasoning-effort="low">
<i data-lucide="zap"></i><span><strong>极速</strong><small>简单指令,优先响应速度</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
<button type="button" class="segment reasoning-option active" data-reasoning-effort="medium">
<i data-lucide="scale"></i><span><strong>均衡</strong><small>日常控制,兼顾速度与理解</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
<button type="button" class="segment reasoning-option" data-reasoning-effort="high">
<i data-lucide="brain"></i><span><strong>深度</strong><small>复杂联动、相对动作与约束</small></span><i class="reasoning-check" data-lucide="check"></i>
</button>
</div>
<p class="provider-note" id="reasoningNote">均衡模式兼顾自然语言解析精度与响应速度</p>
</div>
<div class="field-group">
<span class="field-label">执行目标</span>
<div class="segmented execution-mode-options" role="group" aria-label="执行目标">
<button type="button" class="mode-option" data-execution-mode="dry_run">
<i data-lucide="flask-conical"></i>模拟下发
</button>
<button type="button" class="mode-option active" id="hardwareMode" data-execution-mode="hardware">
<i data-lucide="radio-tower"></i>真实下发
</button>
</div>
</div>
<div class="field-group" id="vehicleTransportField">
<span class="field-label">车辆连接方式</span>
<div class="segmented vehicle-transport-options" role="group" aria-label="车辆连接方式">
<button type="button" class="mode-option active" data-control-transport="wired_can">
<i data-lucide="cable"></i>有线 CAN
</button>
<button type="button" class="mode-option" data-control-transport="wireless_mqtt">
<i data-lucide="wifi"></i>无线 CAN
</button>
</div>
</div>
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
<label class="field-label" for="simulationFault">故障注入场景</label>
<select id="simulationFault" aria-label="选择模拟硬件故障">
<option value="normal">正常反馈</option>
<option value="feedback_timeout">反馈超时</option>
<option value="adapter_disconnect">适配器断开</option>
<option value="motor_stall">电机堵转</option>
<option value="firmware_mismatch">固件不匹配</option>
<option value="partial_feedback">部分反馈</option>
</select>
<p class="provider-note">仅生成模拟 HIL 结果,不会打开物理 CAN 适配器或调用 IAR。</p>
</div>
<div class="field-group vehicle-field">
<label class="field-label" for="vehicleSelect">车型配置</label>
<div class="vehicle-select-control">
<i data-lucide="car-front"></i>
<select id="vehicleSelect" name="vehicle_id" aria-label="选择车型配置">
<option value="">加载车型配置</option>
</select>
</div>
<p class="provider-note" id="vehicleNote">正在读取座椅量程与角度标定</p>
</div>
<button class="primary-action" id="runButton" type="submit" aria-label="发送消息">
<i data-lucide="send-horizontal"></i>
<span>发送</span>
</button>
</form>
</aside>
<main class="main-panel">
<section class="request-header">
<div>
<div class="request-title-row">
<h2 id="resultTitle">等待请求</h2>
<span class="run-status idle" id="runStatus">IDLE</span>
</div>
<p id="requestMeta">Cabin / reference_vehicle / dry-run</p>
</div>
<div class="matrix-stat">
<i data-lucide="table-properties"></i>
<div><strong id="matrixCount">10 / 57</strong><span>报文 / 信号</span></div>
</div>
</section>
<section class="cabin-band" aria-labelledby="cabinStateTitle">
<div class="section-title-row cabin-heading">
<div class="cabin-title-copy">
<h3 id="cabinStateTitle">整车设备状态</h3>
</div>
<div class="cabin-channel-legend" aria-label="整车设备通道说明">
<span data-channel="ch0"><i>CH0</i>副驾座椅</span>
<span data-channel="ch1"><i>CH1</i>主驾 / 车身</span>
<span class="simulation-label" id="feedbackModeLabel">MATRIX-BACKED · SIMULATED FEEDBACK</span>
</div>
</div>
<div class="domain-grid">
<article class="domain-panel climate-panel" data-domain-panel="climate" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="fan"></i></span>
<div><strong>空调</strong><small>CMD 0x1DE · FB 0x404 · CH1</small></div>
<span class="device-state" id="climatePower">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>电源 · 温度 · 风量/风向</p>
<div class="climate-display">
<div class="zone-temperature">
<span>主驾</span><strong id="driverTemperature">--<sup>℃</sup></strong>
<small>风向<b id="driverAirDirection">-</b></small>
</div>
<div class="airflow-visual" id="airflowVisual" data-level="0" aria-label="空调风量">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</div>
<div class="zone-temperature passenger-temperature">
<span>副驾</span><strong id="passengerTemperature">--<sup>℃</sup></strong>
<small>风向<b id="passengerAirDirection">-</b></small>
</div>
</div>
<dl class="domain-readouts">
<div><dt>风量</dt><dd id="fanLevel">未知</dd></div>
<div><dt>循环</dt><dd id="cycleMode">未知</dd></div>
<div><dt>A/C</dt><dd id="acState">未知</dd></div>
<div><dt>前除霜</dt><dd id="defrostState">未知</dd></div>
</dl>
</article>
<article class="domain-panel vehicle-status-panel" data-domain-panel="vehicle_status" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="gauge"></i></span>
<div><strong>车辆状态</strong><small>FB 0x342 · 0x4EB · 0x407 · CH1</small></div>
<span class="device-state readonly-state" id="vehicleTelemetryState">只读</span>
</header>
<p class="domain-capability-line readonly-capability"><span>只读信号</span>档位 · 车速 · 四门锁状态</p>
<div class="vehicle-primary-readout">
<div><span>当前档位</span><strong id="vehicleGear">--</strong></div>
<div><span>实时车速</span><strong id="vehicleSpeed">-- km/h</strong></div>
</div>
<div class="door-state-grid" aria-label="四门锁状态">
<span data-door-state="front_left"><i></i>左前门<b id="frontLeftDoorState">未知</b></span>
<span data-door-state="front_right"><i></i>右前门<b id="frontRightDoorState">未知</b></span>
<span data-door-state="rear_left"><i></i>左后门<b id="rearLeftDoorState">未知</b></span>
<span data-door-state="rear_right"><i></i>右后门<b id="rearRightDoorState">未知</b></span>
</div>
</article>
<article class="domain-panel roof-panel" data-domain-panel="roof" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="panels-top-left"></i></span>
<div><strong>遮阳帘</strong><small>CMD 0x431 · FB 0x41A · CH1</small></div>
<span class="device-state" id="roofState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>开关 / 停止 · 位置</p>
<div class="domain-summary-visual shade-summary-visual" aria-hidden="true">
<span>闭合</span><i><b id="shadePositionMeter"></b></i><span>打开</span>
</div>
<dl class="domain-readouts">
<div><dt>遮阳帘</dt><dd id="shadePositionValue">未知</dd></div>
<div><dt>动作</dt><dd id="shadeMotionState">未知</dd></div>
<div><dt>初始化</dt><dd id="shadeInitializedState">未知</dd></div>
<div><dt>热保护</dt><dd id="shadeThermalState">未知</dd></div>
</dl>
</article>
<article class="domain-panel ambient-panel" data-domain-panel="ambient_light" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="lamp-ceiling"></i></span>
<div><strong>车内灯光</strong><small>INTERIOR / AMBIENT · CH1</small></div>
<span class="device-state" id="ambientPower">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>阅读灯 · 氛围亮度 · 色号</p>
<div class="domain-summary-visual ambient-summary-visual" aria-hidden="true">
<span data-ambient-zone="front"><i id="ambientFrontMeter"></i><b>前排</b></span>
<span data-ambient-zone="rear"><i id="ambientRearMeter"></i><b>后排</b></span>
</div>
<dl class="domain-readouts">
<div><dt>氛围灯</dt><dd id="ambientMode">未知</dd></div>
<div><dt>室内/阅读灯</dt><dd id="interiorLightPower">未知</dd></div>
<div><dt>氛围亮度</dt><dd id="ambientBrightness">- / -</dd></div>
<div><dt>氛围色号</dt><dd id="ambientColorIndex">- / -</dd></div>
</dl>
</article>
<article class="domain-panel window-panel" data-domain-panel="window" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="gallery-vertical-end"></i></span>
<div><strong>车窗</strong><small>CMD 0x431 · FB 0x388 · CH1</small></div>
<span class="device-state" id="windowState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>四窗独立 · 开/关/停 · 位置</p>
<div class="domain-summary-visual window-summary-visual" aria-hidden="true">
<span data-window-meter="front_left"><i></i><b>左前</b></span>
<span data-window-meter="front_right"><i></i><b>右前</b></span>
<span data-window-meter="rear_left"><i></i><b>左后</b></span>
<span data-window-meter="rear_right"><i></i><b>右后</b></span>
</div>
<dl class="domain-readouts">
<div><dt>左前窗</dt><dd id="frontLeftWindow">未知</dd></div>
<div><dt>右前窗</dt><dd id="frontRightWindow">未知</dd></div>
<div><dt>左后窗</dt><dd id="rearLeftWindow">未知</dd></div>
<div><dt>右后窗</dt><dd id="rearRightWindow">未知</dd></div>
</dl>
</article>
<article class="domain-panel lighting-panel" data-domain-panel="front_light" data-device-channel="ch1">
<header>
<span class="domain-icon"><i data-lucide="car-front"></i></span>
<div><strong>前大灯</strong><small>FB 0x38A · CH1</small></div>
<span class="device-state" id="frontLightState">UNKNOWN</span>
</header>
<p class="domain-capability-line"><span>可控参数</span>近/远光 · 雾灯 · 转向灯</p>
<div class="domain-summary-visual lighting-summary-visual" aria-hidden="true">
<span data-light-meter="lowBeamState"><i></i><b>近光</b></span>
<span data-light-meter="highBeamState"><i></i><b>远光</b></span>
<span data-light-meter="fogLightState"><i></i><b>雾灯</b></span>
<span data-light-meter="turnSignalState"><i></i><b>转向</b></span>
</div>
<dl class="domain-readouts">
<div><dt>近光灯</dt><dd id="lowBeamState">未知</dd></div>
<div><dt>远光灯</dt><dd id="highBeamState">未知</dd></div>
<div><dt>雾灯</dt><dd id="fogLightState">未知</dd></div>
<div><dt>转向灯</dt><dd id="turnSignalState">未知</dd></div>
</dl>
</article>
</div>
</section>
<section class="twin-band" aria-labelledby="twinTitle">
<div class="twin-heading">
<div class="twin-title-copy">
<h3 id="twinTitle">整车孪生界面</h3>
<div class="preset-indicator" id="presetIndicator" hidden>
<i data-lucide="bookmark-check"></i><span></span>
</div>
</div>
<div class="twin-toolbar">
<div class="twin-selector" role="tablist" aria-label="前排座椅孪生选择">
<button class="twin-seat-button active" type="button" role="tab" aria-selected="true" data-twin-seat="passenger">副驾座椅 · CH0</button>
<button class="twin-seat-button" type="button" role="tab" aria-selected="false" data-twin-seat="driver">主驾座椅 · CH1</button>
</div>
<button class="icon-button twin-reset" id="twinReset" type="button" title="复位 3D 视角" aria-label="复位 3D 视角">
<i data-lucide="rotate-3d"></i>
</button>
</div>
</div>
<div class="twin-stage" id="seatTwin" data-selected-seat="passenger">
<canvas id="seatTwinCanvas" data-vehicle-model="byd-tang-2024-reference" data-seat-layout="five-seat" data-active-seat="front-right" aria-label="通过模型动画展示 CH0 副驾座椅与 CH1 主驾座椅、空调气流、智能天幕、阅读灯、四门车窗与前大灯状态的整车数字孪生"></canvas>
<div class="twin-seat-label passenger"><i></i><span>副驾座椅</span><small>CH0 · 右前排</small></div>
<div class="twin-seat-label driver"><i></i><span>主驾座椅</span><small>CH1 · 左前排</small></div>
<div class="twin-orientation"><i data-lucide="move-down"></i><span>车头方向</span></div>
<aside class="twin-readout" aria-live="polite">
<div class="twin-readout-heading">
<div><strong id="twinSeatName">副驾座椅</strong><span id="twinSeatZone">CH0 · 右前排</span></div>
<span class="twin-feedback"><i></i>FEEDBACK</span>
</div>
<div class="twin-axis-list">
<div data-twin-axis="longitudinal"><span>水平位置</span><b>50%</b><i></i></div>
<div data-twin-axis="cushion_tilt"><span>坐垫俯仰</span><b>50%</b><i></i></div>
<div data-twin-axis="height"><span>座椅高度</span><b>50%</b><i></i></div>
<div data-twin-axis="backrest_recline"><span>靠背夹角</span><b>20% · 104°</b><i></i></div>
<div data-twin-axis="legrest_height"><span>腿托夹角</span><b>0% · 95°</b><i></i></div>
<div data-twin-axis="legrest_extension"><span>腿托伸缩</span><b>0%</b><i></i></div>
</div>
<div class="twin-thermal-row" aria-label="座椅热舒适状态">
<span data-twin-function="ventilation" data-level="0">
<i data-lucide="wind"></i>
<span class="twin-thermal-copy"><small>通风</small><b>关闭</b></span>
<span class="twin-level-meter" aria-hidden="true"><i></i><i></i><i></i></span>
</span>
<span data-twin-function="heating" data-level="0">
<i data-lucide="flame"></i>
<span class="twin-thermal-copy"><small>加热</small><b>关闭</b></span>
<span class="twin-level-meter" aria-hidden="true"><i></i><i></i><i></i></span>
</span>
</div>
</aside>
<div class="twin-fallback" id="twinFallback"><i data-lucide="loader-circle"></i><span>正在加载整车参考模型</span></div>
</div>
</section>
<section class="state-band">
<div class="seat-state-section">
<div class="section-title-row">
<div><h3>前排座椅状态</h3></div>
<span class="simulation-label" id="seatFeedbackModeLabel">CH0 / CH1 FEEDBACK</span>
</div>
<div class="seat-grid">
<article class="seat-card" data-seat="passenger">
<div class="seat-identity"><i data-lucide="armchair"></i><div><strong>副驾座椅</strong><span>右前排</span></div></div>
<span class="seat-channel-badge" data-seat-feedback="passenger">CH0 · 等待反馈</span>
<div class="dynamic-mode-state" data-dynamic-mode>
<i data-lucide="waves"></i><span>动态模式</span><b>未运行</b>
</div>
<div class="motion-state-grid" aria-live="polite"></div>
</article>
<article class="seat-card" data-seat="driver">
<div class="seat-identity"><i data-lucide="armchair"></i><div><strong>主驾座椅</strong><span>左前排</span></div></div>
<span class="seat-channel-badge" data-seat-feedback="driver">CH1 · 等待反馈</span>
<div class="dynamic-mode-state" data-dynamic-mode>
<i data-lucide="radio-tower"></i><span>车端 CAN FD</span><b>只读同步</b>
</div>
<div class="thermal-states">
<span data-function="ventilation"><i data-lucide="wind"></i>通风 <b>--</b></span>
<span data-function="heating"><i data-lucide="flame"></i>加热 <b>--</b></span>
<span data-function="massage"><i data-lucide="waves"></i>按摩 <b>--</b></span>
</div>
<div class="motion-state-grid" aria-live="polite"></div>
</article>
</div>
</div>
<div class="coverage-section">
<h3>当前控制边界</h3>
<dl>
<div><dt>空调 18–32℃ / 0–7 档</dt><dd class="available">可编译</dd></div>
<div><dt>天幕 / 遮阳帘 0–100%</dt><dd class="available">可编译</dd></div>
<div><dt>氛围灯 RGB / 亮度</dt><dd class="available">可编译</dd></div>
<div><dt>副驾座椅统一执行器</dt><dd class="simulated">t2g_primary_seat</dd></div>
<div><dt>主驾座椅车端执行器</dt><dd class="simulated">CH1 · 左前排</dd></div>
<div><dt>真实台架 CAN 下发</dt><dd class="blocked" id="hardwareCoverage">未配置</dd></div>
</dl>
</div>
</section>
<section class="detail-section result-workspace" aria-labelledby="resultWorkspaceTitle">
<header class="result-workspace-heading">
<div><h3 id="resultWorkspaceTitle">编排结果工作台</h3></div>
<span class="result-workspace-badge"><i data-lucide="route"></i>REQUEST → CONFIG → CAN → RESULT</span>
</header>
<nav class="tabs result-nav" aria-label="结果快速导航">
<button class="tab active" type="button" data-tab="trace"><span class="tab-index">01</span><i data-lucide="list-tree"></i><span class="tab-copy"><strong>决策轨迹</strong><small id="traceNavMeta">等待请求</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="config"><span class="tab-index">02</span><i data-lucide="layout-grid"></i><span class="tab-copy"><strong>配置卡片</strong><small id="configNavMeta">等待生成</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="plan"><span class="tab-index">03</span><i data-lucide="binary"></i><span class="tab-copy"><strong>CAN 计划</strong><small id="planNavMeta">等待编译</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="result"><span class="tab-index">04</span><i data-lucide="circle-check-big"></i><span class="tab-copy"><strong>执行结果</strong><small id="resultNavMeta">尚未执行</small></span><i data-lucide="arrow-down"></i></button>
</nav>
<div class="tab-panel active" id="panel-trace" role="tabpanel">
<div class="workspace-panel-title"><div><h3>决策轨迹与校验</h3></div><span class="workspace-panel-state" id="tracePanelState">IDLE</span></div>
<div class="trace-layout">
<div>
<div class="detail-heading"><h3>可审计决策摘要</h3><span id="providerUsed">--</span></div>
<ol class="decision-list" id="decisionList"><li>提交请求后显示识别目标、能力选择和约束来源。</li></ol>
</div>
<div>
<div class="detail-heading"><h3>阶段事件</h3><span id="traceDuration">0 ms</span></div>
<div class="trace-list" id="traceList"><div class="empty-line">暂无执行事件</div></div>
</div>
</div>
<div class="validation-block">
<div class="detail-heading"><h3>校验结果</h3><span id="validationCount">0 条</span></div>
<div id="validationList" class="validation-list"><div class="empty-line">等待配置生成</div></div>
</div>
</div>
<div class="tab-panel config-panel" id="panel-config" role="tabpanel">
<div class="config-panel-heading">
<div><h3>指令配置与硬件映射</h3></div>
<div class="config-actions"><button class="config-action" type="button" data-copy="config"><i data-lucide="copy"></i>复制 YAML</button><button class="config-action" type="button" data-download="config"><i data-lucide="download"></i>下载配置</button></div>
</div>
<div class="config-card-grid" id="configCards">
<div class="config-empty"><i data-lucide="panel-top-dashed"></i><strong>等待生成配置</strong><span>提交车舱控制指令后,这里会显示控制对象、目标参数和硬件信号。</span></div>
</div>
<details class="config-source-details">
<summary><span><i data-lucide="braces"></i>查看原始配置</span><small id="configFileName">functions/seat.yaml</small></summary>
<pre id="configCode"><code># 等待生成 FunctionConfig</code></pre>
</details>
</div>
<div class="tab-panel plan-panel" id="panel-plan" role="tabpanel">
<div class="workspace-panel-title plan-panel-heading"><div><h3>CAN 编译计划</h3></div><button class="config-action" type="button" data-copy="plan"><i data-lucide="copy"></i>复制计划</button></div>
<div class="frame-summary" id="frameSummary"><span>Frame ID</span><b>--</b><span>Payload</span><code>--</code><span>Mode</span><b>DRY-RUN</b></div>
<details class="config-source-details plan-source-details">
<summary><span><i data-lucide="binary"></i>查看原始 CAN 计划</span><small id="planFileName">command-plans/seat.yaml</small></summary>
<pre id="planCode"><code># 等待 CAN 编译</code></pre>
</details>
</div>
<div class="tab-panel result-panel" id="panel-result" role="tabpanel">
<div class="workspace-panel-title"><div><h3>执行结果与硬件回读</h3></div><span class="workspace-panel-state" id="resultPanelState">IDLE</span></div>
<div class="execution-summary" id="executionSummary">
<i data-lucide="circle-dashed"></i><div><strong>尚未执行</strong><span>运行结果将在此处显示</span></div>
</div>
<div class="result-list" id="resultList"></div>
</div>
</section>
</main>
</div>
<aside class="gesture-drawer" id="gestureDrawer" aria-hidden="true" aria-labelledby="gestureDrawerTitle">
<header class="gesture-drawer-header">
<div>
<h2 id="gestureDrawerTitle">手势控制</h2>
</div>
<button class="gesture-close" id="gestureCloseButton" type="button" aria-label="关闭手势控制"><i data-lucide="x"></i></button>
</header>
<div class="gesture-status-strip">
<span class="gesture-state-pill idle" id="gestureStatePill"><i></i><b>未启动</b></span>
<span id="gestureSafetyText">摄像头尚未开启</span>
</div>
<div class="gesture-camera-stage">
<video id="gestureVideo" playsinline muted></video>
<canvas id="gestureCanvas"></canvas>
<div class="gesture-camera-empty" id="gestureCameraEmpty">
<span><i data-lucide="scan-face"></i></span>
<strong>用手势控制副驾驶座椅</strong>
<p>画面只在本机处理,不上传视频。</p>
</div>
<div class="gesture-live-badge" id="gestureLiveBadge" hidden><i></i>LIVE</div>
</div>
<div class="gesture-now">
<div><span>当前手势</span><strong id="gestureDetected">—</strong></div>
<i data-lucide="arrow-right"></i>
<div><span>期望动作</span><strong id="gestureExpected">等待启动</strong></div>
</div>
<div class="gesture-progress"><i id="gestureProgressBar"></i></div>
<div class="gesture-actions">
<button class="gesture-primary" id="gestureStartButton" type="button"><i data-lucide="camera"></i><span>开启摄像头</span></button>
<button id="gestureStopButton" type="button" disabled><i data-lucide="octagon"></i>停止</button>
</div>
<section class="gesture-guide-card">
<div class="gesture-card-heading"><div><span>手势速查</span><small>张掌激活 · 握拳只停止</small></div><button id="gestureCustomizeButton" type="button"><i data-lucide="sliders-horizontal"></i>自定义</button></div>
<div class="gesture-map-grid" id="gestureMapGrid"></div>
<div class="gesture-customize" id="gestureCustomizePanel" hidden>
<div><span>识别到</span><strong id="gestureCustomizeToken">请先摆出手势</strong></div>
<select id="gestureActionSelect" aria-label="选择座椅动作"></select>
<button id="gestureBindButton" type="button">绑定当前手势</button>
<button id="gestureResetMapping" type="button">恢复默认</button>
</div>
</section>
<footer class="gesture-footnote"><i data-lucide="shield-check"></i><span>换动作无需重新张掌;连续 3 秒无有效动作后自动锁止。</span></footer>
</aside>
<div class="mode-gallery-backdrop" id="modeGalleryBackdrop" hidden>
<section class="mode-gallery" id="modeGallery" role="dialog" aria-modal="true" aria-labelledby="modeGalleryTitle">
<header class="mode-gallery-header">
<div>
<h2 id="modeGalleryTitle">管理功能模式</h2>
<p>按来源与运行方式整理,快速定位并管理模式</p>
</div>
<button class="mode-gallery-close" id="modeGalleryClose" type="button" aria-label="关闭功能模式管理"><i data-lucide="x"></i></button>
</header>
<div class="mode-gallery-summary"><span><i data-lucide="layers-3"></i>自定义模式 · 静态模式 · 动态模式</span><strong id="modeGallerySummaryCount">0</strong></div>
<div class="mode-gallery-groups" id="modeGalleryGroups" aria-live="polite"></div>
<footer class="mode-gallery-footer"><i data-lucide="info"></i><span>自定义模式支持编辑与删除;静态模式执行后保持目标状态;动态模式持续变化,可随时停止。</span></footer>
</section>
</div>
<div class="mode-editor-backdrop" id="modeEditorBackdrop" hidden>
<section class="mode-editor" role="dialog" aria-modal="true" aria-labelledby="modeEditorTitle">
<div class="mode-editor-setup">
<header class="mode-editor-header">
<div>
<h2 id="modeEditorTitle">编辑功能模式</h2>
</div>
<button id="modeEditorClose" type="button" aria-label="关闭模式编辑器"><i data-lucide="x"></i></button>
</header>
<div class="mode-editor-inputs">
<label class="mode-editor-field mode-editor-name-field" for="modeEditorName">
<span>模式名称</span>
<input id="modeEditorName" maxlength="64" autocomplete="off" placeholder="输入模式名称" />
</label>
<label class="mode-editor-field mode-editor-instruction-field" for="modeEditorInstruction">
<span>自然语言指令</span>
<textarea id="modeEditorInstruction" maxlength="1000" rows="3" placeholder="例如:空调24度、风量2档,打开遮阳帘"></textarea>
</label>
</div>
</div>
<section class="mode-editor-flow" aria-live="polite">
<header>
<span><i data-lucide="workflow"></i>控制指令流</span>
<div class="mode-editor-flow-tools">
<b id="modeEditorFlowCount">尚未生成</b>
<button id="modeEditorFlowExpand" type="button" aria-expanded="false" aria-label="展开控制指令区域" title="展开控制指令区域">
<i data-lucide="maximize-2"></i>
</button>
</div>
</header>
<div class="mode-editor-flow-preview empty" id="modeEditorFlowPreview">
<p>点击“生成指令”后,这里会显示由后端解析、校验并编译的控制指令流。</p>
</div>
</section>
<p class="mode-editor-status" id="modeEditorStatus">修改自然语言后需要重新生成指令,才能点击完成永久保存。</p>
<footer class="mode-editor-actions">
<button id="modeEditorCancel" type="button">取消</button>
<button class="mode-editor-execute" id="modeEditorExecute" type="button"><i data-lucide="wand-sparkles" data-icon="inline-start"></i><span>生成指令</span></button>
<button class="mode-editor-complete" id="modeEditorComplete" type="button" disabled><i data-lucide="save" data-icon="inline-start"></i><span>完成</span></button>
</footer>
</section>
</div>
<button class="glass-theme-toggle" id="glassThemeToggle" type="button" aria-label="切换到白日模式" aria-pressed="true" title="切换到白日模式">
<i data-lucide="sun" data-theme-icon="sun" aria-hidden="true"></i>
<i data-lucide="moon" data-theme-icon="moon" aria-hidden="true"></i>
<span class="visually-hidden">切换白日/夜间模式</span>
</button>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</template>
+33
View File
@@ -0,0 +1,33 @@
import { createApp, nextTick } from "vue";
import App from "./App.vue";
import "../styles.css";
import "../console-v2.css";
import "../self-extension.css";
import "../speech-input.css";
import "../assistant-chat.css";
import "../energy-monitor.css";
import "../ui-consistency.css";
import "../codepen-glass-theme.css";
import "./styles/design-system.css";
import "../vue-ui.js";
const application = createApp(App);
application.config.errorHandler = (error, instance, info) => {
console.error("N7 Vue application error", { error, instance, info });
};
application.mount("#app");
document.querySelector("#app")?.removeAttribute("aria-busy");
document.documentElement.classList.add("vue-vite-application");
document.documentElement.dataset.vueVersion = application.version || "3";
nextTick(() => {
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
window.dispatchEvent(new CustomEvent("cognitive-agent:vue-mounted"));
});
window.CognitiveAgentVueApp = Object.freeze({
application,
version: application.version || "3"
});
@@ -0,0 +1,389 @@
/* N7 Web Console design-system contract.
* Loaded last by Vite so every legacy surface consumes one geometry,
* typography, state and elevation scale while the runtime is migrated to
* Vue components incrementally. */
:root {
--n7-control-height-sm: 32px;
--n7-control-height: 38px;
--n7-icon-button-size: 38px;
--n7-radius-control: 10px;
--n7-radius-card: 14px;
--n7-radius-section: 16px;
--n7-radius-panel: 18px;
--n7-radius-dialog: 22px;
--n7-font-caption: 12px;
--n7-font-body: 13px;
--n7-font-card-title: 14px;
--n7-font-panel-title: 16px;
--n7-font-dialog-title: 22px;
--n7-shadow-card: inset 0 1px 0 var(--glass-highlight), 0 7px 20px color-mix(in oklch, var(--foreground) 8%, transparent);
--n7-shadow-panel: inset 0 1px 0 var(--glass-highlight), 0 14px 34px color-mix(in oklch, var(--foreground) 13%, transparent);
--n7-shadow-dialog: inset 0 1px 0 var(--glass-highlight), 0 28px 84px color-mix(in oklch, var(--foreground) 28%, transparent);
}
#app {
min-width: inherit;
min-height: 100dvh;
display: contents;
}
/* Workspace panels: one top-level geometry, distinct from their children. */
body.console-v2.v2-passive-display.v2-interactive-sync :is(
.main-panel > .v2-mode-rail,
#assistantDrawer,
.joint-twin-shell > .twin-band,
#v2DevicePanel,
#v2ExtensionPanel .v2-extension-sync-mount
) {
border-radius: var(--n7-radius-panel) !important;
box-shadow: var(--n7-shadow-panel) !important;
}
body.console-v2 :is(
.v2-sync-transport-field,
.v2-sync-execution-field,
.v2-can-command-panel,
.v2-control-link-card,
.v2-nfc-inline,
.v2-extension-gateway,
.v2-extension-synced-device,
.v2-extension-empty,
.v2-pet-card,
.v2-pet-presence-card,
.v2-device-card,
.v2-synced-device,
.mode-gallery-card,
.mode-editor-command-card,
.energy-kpi,
.energy-device-row,
.assistant-dispatched-controls,
.assistant-mode-draft,
.assistant-mode-created,
.assistant-control-suggestion
) {
border-radius: var(--n7-radius-card) !important;
box-shadow: var(--n7-shadow-card) !important;
}
body.console-v2 :is(
.v2-sync-log,
.v2-pet-card-region,
.mode-gallery-group,
.mode-editor-flow,
.v2-nfc-stage,
.energy-trend-card,
.energy-devices-card
) {
border-radius: var(--n7-radius-section) !important;
}
/* Only actionable cards float. Read-only panels remain spatially stable. */
body.console-v2 :is(
.v2-sync-log,
.v2-pet-card-region,
.mode-editor-flow,
.energy-trend-card,
.energy-devices-card
):is(:hover, :focus-within) {
transform: none !important;
box-shadow: var(--n7-shadow-card) !important;
}
/* The current DOM uses .twin-selector; the previous glass selector targeted
* the removed .twin-seat-switch class and produced white-on-white tabs. */
body.console-v2 .joint-twin-shell :is(.twin-selector, .twin-selector button, .twin-reset, .twin-focus-badge) {
color: var(--muted-foreground) !important;
background: var(--glass-card) !important;
border-color: var(--glass-line) !important;
box-shadow: inset 0 1px 0 var(--glass-highlight) !important;
}
body.console-v2 .joint-twin-shell .twin-selector button.active {
color: var(--primary-foreground) !important;
background: var(--primary) !important;
border-color: color-mix(in oklch, var(--primary) 72%, white 18%) !important;
}
/* Shared heading hierarchy. */
body.console-v2 :is(
#assistantDrawer h2,
.joint-twin-shell .twin-heading h3,
.v2-device-sync-board > header h3,
.v2-extension-sync-heading h3,
.v2-mode-rail .mode-rail-title
) {
font-size: var(--n7-font-panel-title) !important;
font-weight: 750 !important;
line-height: 1.25 !important;
letter-spacing: -0.02em;
}
/* The log title shares a 217 px header with its filter, so it needs the
* compact card-title scale instead of the full panel-title scale. */
body.console-v2 .v2-sync-log h3 {
min-width: 0;
margin: 0;
overflow: hidden;
font-size: var(--n7-font-card-title) !important;
font-weight: 750 !important;
line-height: 1.25 !important;
letter-spacing: -0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Sidebar disclosure, connection status and control-command readability use
* the same semantic tokens in both day and night themes. */
body.console-v2 .v2-sync-log[data-collapsed="true"] {
min-height: 0;
align-self: start;
grid-template-rows: auto;
flex: 0 0 auto;
}
body.console-v2 .v2-sync-log[data-collapsed="true"] > header {
border-bottom-color: transparent;
}
body.console-v2 .v2-sync-log[data-collapsed="true"] #v2LogFilter {
display: none;
}
body.console-v2 .v2-sync-log #v2SyncLogList[hidden] {
display: none !important;
}
body.console-v2 .v2-sync-log > header > div:first-child {
min-width: 0;
}
body.console-v2 .v2-sync-log .v2-sync-log-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
body.console-v2 .v2-sync-log .v2-sync-log-toggle {
min-width: 46px;
min-height: var(--n7-control-height-sm);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 7px;
color: var(--muted-foreground);
background: var(--secondary);
border: 1px solid var(--input);
border-radius: var(--n7-radius-control);
cursor: pointer;
}
body.console-v2 .v2-sync-log .v2-sync-log-toggle:is(:hover, :focus-visible) {
color: var(--accent-foreground);
background: var(--accent);
border-color: var(--ring);
outline: none;
}
body.console-v2 .v2-sync-log .v2-sync-log-toggle svg {
width: 15px;
height: 15px;
transition: transform 160ms ease;
}
body.console-v2 .v2-sync-log .v2-sync-log-toggle[aria-expanded="true"] svg {
transform: rotate(180deg);
}
body.console-v2.v2-passive-display.v2-interactive-sync .v2-control-link-card {
min-height: 56px;
grid-template-columns: 10px minmax(0, 1fr);
gap: 10px;
margin: 0 !important;
padding: 10px 11px !important;
color: var(--card-foreground);
background: var(--glass-card) !important;
border: 1px solid var(--glass-line) !important;
border-radius: var(--n7-radius-card) !important;
box-shadow: var(--n7-shadow-card) !important;
-webkit-backdrop-filter: blur(16px) saturate(1.2);
backdrop-filter: blur(16px) saturate(1.2);
}
body.console-v2 .v2-control-link-card strong {
color: var(--card-foreground) !important;
}
body.console-v2 .v2-control-link-card small {
color: var(--muted-foreground) !important;
}
body.console-v2 .v2-control-link-card[data-state="online"] .v2-live-dot {
background: var(--status-online);
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-online) 18%, transparent);
}
body.console-v2 .v2-control-link-card[data-state="connecting"] .v2-live-dot {
background: var(--status-info);
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-info) 18%, transparent);
}
body.console-v2 .v2-control-link-card[data-state="degraded"] .v2-live-dot {
background: var(--status-warning);
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-warning) 18%, transparent);
}
body.console-v2 .v2-control-link-card[data-state="offline"] .v2-live-dot {
background: var(--status-offline);
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-offline) 18%, transparent);
}
body.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list li[data-can-channel]
.v2-can-command-actual code {
color: var(--card-foreground) !important;
}
body.console-v2 :is(
.mode-gallery-header h2,
.mode-editor-header h2,
.v2-extension-dialog > header h2,
.v2-extension-dialog form > header h2,
.energy-monitor-heading h2
) {
font-size: var(--n7-font-dialog-title) !important;
font-weight: 750 !important;
line-height: 1.2 !important;
}
body.console-v2 :is(
.mode-gallery-card-heading strong,
.v2-device-card-focus strong,
.v2-nfc-inline strong,
.v2-extension-gateway strong,
.v2-pet-card > header > strong,
.energy-trend-card h3,
.energy-devices-card h3
) {
font-size: var(--n7-font-card-title) !important;
line-height: 1.35 !important;
}
body.console-v2 :is(
.mode-editor-status,
.mode-gallery-footer,
.v2-pet-settings-footnote,
.v2-pet-card-fields > label > span,
.v2-pet-nfc-field output,
.energy-kpi > small,
.energy-kpi > em,
.energy-devices-card > footer
) {
font-size: var(--n7-font-caption) !important;
line-height: 1.45 !important;
}
/* One dialog radius, elevation and close-button size. */
body.console-v2 :is(
.mode-gallery,
.mode-editor,
.v2-nfc-dialog,
.v2-gscu-tcp-dialog,
.v2-pet-settings-dialog,
.energy-monitor-page
) {
border-radius: var(--n7-radius-dialog) !important;
box-shadow: var(--n7-shadow-dialog) !important;
}
body.console-v2 :is(
.mode-gallery-close,
.mode-editor-header > button,
.v2-extension-dialog [data-dialog-close],
.v2-extension-dialog [data-close-extension-dialog],
.v2-extension-dialog [data-close-nfc-dialog],
#energyMonitorClose
) {
width: var(--n7-icon-button-size) !important;
height: var(--n7-icon-button-size) !important;
min-width: var(--n7-icon-button-size);
min-height: var(--n7-icon-button-size);
border-radius: var(--n7-radius-control) !important;
}
/* Compact icon actions still meet a usable hit target without becoming a
* second visual system. */
body.console-v2 :is(
.twin-reset,
.mode-gallery-edit,
.mode-gallery-delete,
.v2-pet-card > header > button
) {
width: var(--n7-control-height-sm) !important;
height: var(--n7-control-height-sm) !important;
min-width: var(--n7-control-height-sm);
min-height: var(--n7-control-height-sm);
border-radius: var(--n7-radius-control) !important;
}
/* Offline hardware is a destructive state, not a primary/active state. */
body.console-v2 .v2-nfc-stage:has(.v2-nfc-waiting.offline) > header {
color: var(--status-error-foreground) !important;
background: color-mix(in oklch, var(--destructive) 16%, var(--card)) !important;
border-bottom: 1px solid color-mix(in oklch, var(--destructive) 32%, var(--border));
}
body.console-v2 .v2-nfc-stage:has(.v2-nfc-waiting.offline) > header :is(span, small, h3, p, b) {
color: var(--status-error-foreground) !important;
}
/* Keep the global appearance control out of content and below every modal. */
.glass-theme-toggle {
left: max(18px, env(safe-area-inset-left)) !important;
bottom: max(18px, env(safe-area-inset-bottom)) !important;
z-index: 1100 !important;
}
body.mode-gallery-open .glass-theme-toggle,
body.mode-editor-open .glass-theme-toggle,
body:has(.v2-extension-dialog[open]) .glass-theme-toggle,
body:has(.energy-monitor-layer:not([hidden])) .glass-theme-toggle {
opacity: 0;
pointer-events: none;
}
/* The sidebar reserves the floating control's footprint instead of allowing
* it to cover the log timeline. */
body.console-v2 .v2-sync-log {
margin-bottom: 58px;
}
/* Top mode rail communicates that more content is available. */
body.console-v2 .v2-mode-rail .mode-rail-content {
position: relative;
min-width: 0;
}
body.console-v2 .v2-mode-rail .mode-rail-content::after {
width: 28px;
content: "";
position: absolute;
inset: 0 0 0 auto;
pointer-events: none;
background: linear-gradient(90deg, transparent, color-mix(in oklch, var(--glass-panel) 94%, transparent));
}
@media (max-width: 1360px) {
.glass-theme-toggle {
width: 42px !important;
height: 42px !important;
}
}
@media (prefers-reduced-motion: reduce) {
body.console-v2 * {
scroll-behavior: auto !important;
}
}
File diff suppressed because it is too large Load Diff
+419
View File
@@ -0,0 +1,419 @@
/* Shared spatial UI contract --------------------------------------------
Keep geometry consistent across legacy modules without changing their
domain-specific colors, content density, or interaction behavior.
Hierarchy: control 10px -> card 13px -> section 15px -> panel 16px ->
modal 20px. All modules consume the same semantic color tokens. */
:root {
--ui-control-height-compact: 30px;
--ui-control-height: 38px;
--ui-control-size-icon: 36px;
--ui-font-card-title: 13px;
--ui-font-section-title: 15px;
--ui-font-panel-title: 16px;
--ui-inline-action-width: 88px;
--ui-radius-control-inner: calc(var(--radius) - 2px);
--ui-radius-control: var(--radius);
--ui-radius-item: calc(var(--radius) + 2px);
--ui-radius-section: calc(var(--radius) + 6px);
--ui-radius-panel: calc(var(--radius) + 8px);
--ui-radius-modal: calc(var(--radius) + 12px);
--ui-space-card: 10px;
--ui-border-subtle: 1px solid var(--border);
--ui-shadow-item: 0 5px 14px color-mix(in oklch, var(--foreground) 6%, transparent);
--ui-shadow-panel:
0 14px 32px color-mix(in oklch, var(--foreground) 8%, transparent),
inset 0 1px 0 color-mix(in oklch, var(--card) 78%, transparent);
--ui-shadow-modal:
0 28px 72px color-mix(in oklch, var(--foreground) 22%, transparent),
0 4px 18px color-mix(in oklch, var(--primary) 8%, transparent),
inset 0 1px 0 color-mix(in oklch, var(--card) 84%, transparent);
--ui-focus-ring: 0 0 0 3px color-mix(in oklch, var(--ring) 22%, transparent);
}
/* One readable type scale prevents large legacy headings and tiny newer card
titles from competing inside the same workspace. */
.console-v2 :is(
.v2-operation-heading h2,
.joint-twin-shell .twin-heading h3,
.v2-device-sync-board > header h3,
.v2-extension-sync-heading h3,
.v2-sync-sidebar-heading h2,
.workspace-panel-title h3,
.config-panel-heading h3
) {
font-size: var(--ui-font-panel-title) !important;
line-height: 1.25;
letter-spacing: -0.02em;
}
.console-v2 :is(
.v2-pet-card-region > header h4,
.v2-gscu-actions > header strong,
.v2-gscu-command-catalog > header strong
) {
font-size: var(--ui-font-section-title) !important;
line-height: 1.3;
}
.console-v2 :is(
.v2-device-card-focus strong,
.v2-nfc-inline strong,
.v2-extension-gateway strong,
.v2-extension-synced-device header strong,
.v2-pet-presence-card > header strong,
.v2-pet-card > header > strong
) {
font-size: var(--ui-font-card-title) !important;
line-height: 1.35;
}
/* Top-level workspace surfaces share one frame. */
.console-v2.v2-passive-display.v2-interactive-sync :is(
.v2-operation-shell,
.joint-twin-shell > .twin-band,
#v2DevicePanel,
#v2ExtensionPanel .v2-extension-sync-mount
) {
border: var(--ui-border-subtle);
border-radius: var(--ui-radius-panel);
box-shadow: var(--ui-shadow-panel);
}
/* The operation shell owns the frame; its active view must not draw a
second border directly inside the same spatial boundary. */
.console-v2.v2-passive-display.v2-interactive-sync
.v2-operation-shell > .v2-natural-panel {
border: 0;
border-radius: inherit;
box-shadow: none;
}
/* Modal surfaces and their backdrops use one elevation level. */
:is(
.mode-gallery-backdrop .mode-gallery,
.mode-editor-backdrop .mode-editor,
.energy-monitor-layer .energy-monitor-page
) {
border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
border-radius: var(--ui-radius-modal);
box-shadow: var(--ui-shadow-modal);
}
.mode-gallery-backdrop,
.mode-editor-backdrop,
.energy-monitor-layer {
background: color-mix(in oklch, var(--foreground) 42%, transparent);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
}
/* Section containers sit between panels and individual cards. */
.v2-sync-log,
.mode-gallery-group,
.energy-trend-card,
.energy-devices-card {
border-radius: var(--ui-radius-section);
}
/* Repeated content cards share radius and elevation while retaining their
own semantic state colors and specialized internal layouts. */
.v2-synced-device,
.v2-extension-gateway,
.v2-nfc-inline,
.mode-gallery-card,
.mode-editor-flow,
.mode-editor-command-card,
.energy-kpi {
border-radius: var(--ui-radius-item);
box-shadow: var(--ui-shadow-item);
}
.energy-kpi-primary {
box-shadow: 0 8px 20px color-mix(in oklch, var(--primary) 10%, transparent);
}
.v2-extension-gateway,
.v2-nfc-inline {
padding: var(--ui-space-card);
}
/* Sidebar cards and their nested segmented controls preserve a clear
parent/child radius relationship. */
.console-v2 :is(
.v2-sync-transport-field,
.v2-sync-execution-field,
.v2-can-command-panel
) {
border-radius: var(--ui-radius-item);
}
/* The compact-sidebar rules in console-v2.css intentionally use highly
specific selectors. Keep all peer cards on the shared item radius even
when that layout is active. */
.console-v2.v2-passive-display.v2-interactive-sync :is(
.v2-sync-transport-field,
.v2-sync-execution-field,
.v2-can-command-panel
) {
border-radius: var(--ui-radius-item) !important;
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-log {
border-radius: var(--ui-radius-section) !important;
}
.console-v2 :is(.segmented, .twin-selector) {
gap: 4px;
padding: 3px;
background: var(--muted);
border: var(--ui-border-subtle);
border-radius: var(--ui-radius-control);
}
.console-v2 .segmented > :is(.segment, .mode-option),
.console-v2 .twin-selector > .twin-seat-button {
border-radius: var(--ui-radius-control-inner);
}
.console-v2 .segmented > .mode-option {
min-height: var(--ui-control-height);
height: var(--ui-control-height);
gap: 5px;
}
.console-v2 .twin-selector > .twin-seat-button {
height: 32px;
}
/* Primary header controls use one medium size. */
.console-v2 #v2PanelControlDock > .v2-panel-control-button,
.console-v2.v2-passive-display.v2-interactive-sync
.v2-mode-rail .mode-rail-actions > :is(
#modeGalleryToggle,
#saveModeToggle,
#modeRailToggle
) {
min-height: var(--ui-control-height);
height: var(--ui-control-height);
border-radius: var(--ui-radius-control);
}
.console-v2.v2-passive-display.v2-interactive-sync
#v2PanelControlDock > .v2-panel-control-button,
.console-v2.v2-passive-display.v2-interactive-sync
#v2PanelControlDock > #v2SidebarToggle.v2-panel-control-button {
border-radius: var(--ui-radius-control) !important;
}
.console-v2.v2-passive-display.v2-interactive-sync
.v2-mode-rail #modeRailToggle {
width: var(--ui-control-height);
min-width: var(--ui-control-height);
}
/* Repeated inline actions use one compact size. */
.console-v2 :is(
.v2-device-adjust-toggle,
.v2-pet-settings-launch,
.v2-extension-nfc-launch,
.v2-extension-gateway > button,
.v2-nfc-inline button
),
.energy-trend-tabs button {
min-height: var(--ui-control-height-compact);
height: var(--ui-control-height-compact);
border-radius: var(--ui-radius-control-inner);
}
/* Related extension actions use equal columns and a shared trailing action
width, so every card keeps the same horizontal rhythm. */
.console-v2 .v2-extension-sync-actions {
width: calc(var(--ui-inline-action-width) * 2 + 6px);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.console-v2 .v2-extension-sync-actions > :is(
.v2-pet-settings-launch,
.v2-extension-nfc-launch
) {
width: 100%;
min-width: 0;
height: var(--ui-control-height-compact);
justify-content: center;
padding-inline: 7px;
font-size: 10px;
}
.console-v2 :is(
.v2-nfc-inline > button,
.v2-extension-gateway > button
) {
width: var(--ui-inline-action-width);
min-width: var(--ui-inline-action-width);
justify-content: center;
}
/* Header icon actions are distinct from circular record/send actions. */
.assistant-drawer-actions .context-reset,
.assistant-drawer-close,
.mode-gallery-close,
.mode-editor-header > button,
#energyMonitorClose {
width: var(--ui-control-size-icon);
min-width: var(--ui-control-size-icon);
height: var(--ui-control-size-icon);
min-height: var(--ui-control-size-icon);
border-radius: var(--ui-radius-control);
}
/* Text fields align with medium controls and use the same focus treatment. */
.mode-editor-field input,
.mode-editor-field textarea {
border-radius: var(--ui-radius-control);
}
/* Reserve the right side for the microphone/send actions. Anchoring the hint
from the content edge prevents the legacy right offsets from pushing it
outside narrow assistant composers. */
.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync
.v2-request-form .composer-hint {
left: 16px !important;
right: auto !important;
bottom: 8px;
max-width: calc(100% - 132px);
}
/* Delivery states use the same semantic feedback language in the sidebar and
in the compact assistant command card. */
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list
li[data-can-channel]
.v2-can-command-delivery[data-delivery-state="failed"] {
color: var(--destructive);
background: color-mix(in oklch, var(--destructive) 10%, var(--card));
border-color: color-mix(in oklch, var(--destructive) 28%, var(--border));
}
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list
li[data-can-channel]
.v2-can-command-delivery[data-delivery-state="blocked"] {
color: var(--status-warning-foreground);
background: var(--status-warning-soft);
border-color: color-mix(in oklch, var(--status-warning) 38%, var(--border));
}
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list
li[data-can-channel]
.v2-can-command-delivery[data-delivery-state="aborted"] {
color: var(--muted-foreground);
background: var(--muted);
border-color: var(--border);
}
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list
li[data-can-channel]
.v2-can-command-delivery-detail {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px;
margin: 1px 0 2px;
padding: 5px 7px;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--muted) 62%, transparent);
border-left: 2px solid var(--border);
border-radius: var(--ui-radius-control-inner);
font-size: 9px;
line-height: 1.45;
white-space: normal;
}
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list
li[data-delivery-state="failed"]
.v2-can-command-delivery-detail {
color: var(--status-error-foreground);
border-left-color: var(--destructive);
}
.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list
li[data-delivery-state="blocked"]
.v2-can-command-delivery-detail {
color: var(--status-warning-foreground);
border-left-color: var(--status-warning);
}
.assistant-dispatched-controls-list > li[data-delivery-state="failed"] {
background: color-mix(in oklch, var(--destructive) 4%, var(--card));
}
.assistant-dispatched-controls-list
> li[data-delivery-state="failed"]
.assistant-dispatched-control-icon {
color: var(--destructive);
}
.assistant-dispatched-controls-list > li[data-delivery-state="blocked"] {
background: var(--status-warning-soft);
}
.assistant-dispatched-controls-list
> li[data-delivery-state="blocked"]
.assistant-dispatched-control-icon {
color: var(--status-warning-foreground);
}
.assistant-dispatched-controls-list > li[data-delivery-state="aborted"] {
background: color-mix(in oklch, var(--muted) 68%, transparent);
}
.assistant-dispatched-controls-list
> li[data-delivery-state="aborted"]
.assistant-dispatched-control-icon {
color: var(--muted-foreground);
}
.console-v2 :is(
#v2PanelControlDock > .v2-panel-control-button,
.v2-mode-rail .mode-rail-actions > button,
.segmented > button,
.twin-selector > button,
.v2-device-adjust-toggle,
.v2-pet-settings-launch,
.v2-extension-nfc-launch,
.v2-extension-gateway > button,
.v2-nfc-inline button
):focus-visible,
.mode-gallery :is(button, input, textarea):focus-visible,
.mode-editor :is(button, input, textarea):focus-visible,
.energy-monitor-page button:focus-visible {
outline: none;
box-shadow: var(--ui-focus-ring);
}
@media (max-width: 900px) {
:root {
--ui-control-height: 40px;
--ui-control-height-compact: 32px;
--ui-control-size-icon: 38px;
}
.console-v2 .v2-extension-sync-actions {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.v2-synced-device,
.mode-gallery-card,
.energy-kpi,
.console-v2 button {
transition-duration: 0.01ms !important;
}
}
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2018-present, Yuxi (Evan) You
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
+177
View File
@@ -0,0 +1,177 @@
import { createHash } from "node:crypto";
import {
cpSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, extname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
const webRoot = dirname(fileURLToPath(import.meta.url));
const outputRoot = resolve(webRoot, "dist");
const backendOrigin = "http://127.0.0.1:8000";
const runtimeDirectories = ["assets", "icons", "vendor"];
const runtimeFiles = [
"manifest.webmanifest",
"service-worker.js",
"speech-audio-worklet.js",
"login.html",
"login.css",
"login.js",
"hardware-control.html",
"hardware-control.css",
"hardware-control.js",
"network.js",
"app.js",
"gesture-control.js",
"pwa.js",
"console-v2.js",
"self-extension.js",
"speech-input.js",
"assistant-chat.js",
"energy-monitor.js",
"firmware-workbench-entry.js",
"firmware-workbench-theme.js",
"firmware-workbench.js",
"firmware-workbench.html",
"styles.css",
"console-v2.css",
"self-extension.css",
"speech-input.css",
"assistant-chat.css",
"energy-monitor.css",
"firmware-workbench-entry.css",
"firmware-workbench.css",
"ui-consistency.css",
"codepen-glass-theme.css",
"vue-ui.js"
];
const fingerprintedRuntimeFiles = runtimeFiles.filter((file) => (
/\.(?:css|js)$/.test(file)
&& !["service-worker.js", "speech-audio-worklet.js"].includes(file)
));
const rewrittenHtmlFiles = [
"index.html",
"login.html",
"hardware-control.html",
"firmware-workbench.html",
];
function contentHash(value) {
return createHash("sha256").update(value).digest("hex").slice(0, 12);
}
function hashedRuntimeName(file, hash) {
const extension = extname(file);
return `${basename(file, extension)}.${hash}${extension}`;
}
function rewriteRuntimeReferences(value, manifest) {
let rewritten = value;
for (const [source, target] of Object.entries(manifest)) {
const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
rewritten = rewritten.replace(
new RegExp(`/${escaped}(?:\\?[^\\s\"'<>)]*)?`, "g"),
`/${target}`,
);
}
return rewritten;
}
function fingerprintRuntimeAssets() {
const manifest = {};
for (const file of fingerprintedRuntimeFiles) {
const source = resolve(outputRoot, file);
if (!existsSync(source)) continue;
const contents = readFileSync(source);
const hashedName = hashedRuntimeName(file, contentHash(contents));
writeFileSync(resolve(outputRoot, hashedName), contents);
manifest[file] = hashedName;
}
for (const file of rewrittenHtmlFiles) {
const target = resolve(outputRoot, file);
if (!existsSync(target)) continue;
const source = readFileSync(target, "utf8");
writeFileSync(target, rewriteRuntimeReferences(source, manifest), "utf8");
}
const serviceWorkerPath = resolve(outputRoot, "service-worker.js");
const buildHash = contentHash(JSON.stringify(manifest));
if (existsSync(serviceWorkerPath)) {
const serviceWorker = rewriteRuntimeReferences(
readFileSync(serviceWorkerPath, "utf8"),
manifest,
).replace(
/const CACHE_VERSION = "[^"]+";/,
`const CACHE_VERSION = "cognitive-agent-pad-build-${buildHash}";`,
);
writeFileSync(serviceWorkerPath, serviceWorker, "utf8");
}
writeFileSync(
resolve(outputRoot, "runtime-assets.json"),
`${JSON.stringify({ build_hash: buildHash, assets: manifest }, null, 2)}\n`,
"utf8",
);
}
function copyRuntimeAssets() {
return {
name: "copy-n7-runtime-assets",
apply: "build",
closeBundle() {
for (const directory of runtimeDirectories) {
const source = resolve(webRoot, directory);
if (existsSync(source)) {
cpSync(source, resolve(outputRoot, directory), { recursive: true });
}
}
for (const file of runtimeFiles) {
const source = resolve(webRoot, file);
if (!existsSync(source)) continue;
const target = resolve(outputRoot, file);
mkdirSync(dirname(target), { recursive: true });
copyFileSync(source, target);
}
fingerprintRuntimeAssets();
}
};
}
function backendProxy() {
return {
target: backendOrigin,
changeOrigin: true,
configure(proxy) {
proxy.on("proxyReq", (request) => request.setHeader("origin", backendOrigin));
}
};
}
export default defineConfig({
root: webRoot,
publicDir: false,
plugins: [vue(), copyRuntimeAssets()],
server: {
strictPort: true,
fs: { allow: [webRoot] },
proxy: {
"/api": backendProxy(),
"/artifacts": backendProxy()
}
},
build: {
outDir: outputRoot,
emptyOutDir: true,
assetsDir: "vite-assets",
target: "es2022",
sourcemap: true
}
});
+190
View File
@@ -0,0 +1,190 @@
import { Fragment, createApp, h, nextTick, reactive } from "vue";
(function () {
"use strict";
const VUE_VERSION = "3.5.21";
const modeMounts = new WeakMap();
const commandMounts = new WeakMap();
const text = (value, fallback = "") => String(value ?? fallback);
function refreshIcons() {
nextTick(() => window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }));
}
function replaceReactiveArray(target, values) {
target.splice(0, target.length, ...values.map((value) => ({ ...value })));
}
function renderModeEntry(entry) {
const key = text(entry.key || `${entry.type}-${entry.id || entry.label}`);
if (entry.type === "label") {
return h("span", {
key,
class: "mode-group-label",
"data-mode-group-label": text(entry.id),
}, text(entry.label));
}
if (entry.type === "divider") {
return h("span", {
key,
class: "mode-group-divider",
"data-mode-divider": text(entry.id),
"aria-hidden": "true",
});
}
const children = [
h("i", {
class: "mode-chip-icon",
"data-lucide": text(entry.icon, "bookmark"),
}),
h("span", { class: "mode-chip-label" }, text(entry.label, "模式")),
h("small", { class: "mode-chip-summary" }, text(entry.summary, "整车联动")),
];
if (entry.cycleLabel) {
children.push(h("small", { class: "mode-cycle-badge" }, text(entry.cycleLabel)));
}
children.push(h("i", {
class: "mode-chip-run",
"data-lucide": "play",
"data-mode-run": "true",
title: `执行${text(entry.label, "模式")}`,
}));
return h("button", {
key,
type: "button",
title: text(entry.title),
"data-mode-profile-id": text(entry.id),
"data-mode-command": text(entry.command),
"data-mode-origin": text(entry.origin, "preset"),
...(entry.behavior ? { "data-mode-behavior": text(entry.behavior) } : {}),
"aria-label": text(entry.ariaLabel, `${text(entry.label, "模式")},点击执行`),
"aria-pressed": "false",
}, children);
}
function createModeMount(container) {
const state = reactive({ entries: [] });
const app = createApp({
name: "N7ModeChipRail",
setup() {
return () => h(Fragment, null, state.entries.map(renderModeEntry));
},
});
app.mount(container);
const mount = { app, state };
modeMounts.set(container, mount);
return mount;
}
function renderModeChips(container, entries) {
if (!(container instanceof Element) || !Array.isArray(entries)) return false;
const mount = modeMounts.get(container) || createModeMount(container);
replaceReactiveArray(mount.state.entries, entries);
container.dataset.vueSurface = "mode-chips";
refreshIcons();
return true;
}
function renderCommandRow(command, index) {
const semantic = text(command.semantic);
const actual = text(command.command);
const accessibleText = [
semantic ? `语义控制指令:${semantic}` : "",
actual ? `实际控制指令:${actual}` : "",
command.deliveryLabel
? `下发状态:${text(command.deliveryLabel)}${command.deliveryDetail ? `(${text(command.deliveryDetail)})` : ""}`
: "",
].filter(Boolean).join(";");
return h("li", {
key: text(command.key, `command-${index}`),
title: accessibleText,
"data-delivery-state": text(command.deliveryState, "not_submitted"),
}, [
h("span", { class: "assistant-dispatched-control-icon" }, [
h("i", { "data-lucide": text(command.icon, "binary") }),
]),
h("span", { class: "assistant-dispatched-control-label" }, text(command.category, "控制指令")),
h("strong", { class: "assistant-dispatched-control-value" }, text(command.value, "控制")),
h("small", { class: "visually-hidden" }, accessibleText),
]);
}
function createCommandMount(container) {
const state = reactive({ commands: [], open: true, heading: "下发控制指令", footer: null, hideCommands: false });
const app = createApp({
name: "N7DispatchedControlsCard",
setup() {
return () => h("details", {
class: "assistant-dispatched-controls",
open: state.open,
onToggle(event) {
state.open = event.currentTarget.open;
if (!state.open) return;
const transcript = document.querySelector("#assistantMessages");
transcript?.querySelectorAll(".assistant-dispatched-controls[open]").forEach((card) => {
if (card !== event.currentTarget) card.open = false;
});
},
}, [
h("summary", null, [
h("span", { class: "assistant-dispatched-controls-heading-icon" }, [
h("i", { "data-lucide": "list-tree" }),
]),
h("b", null, state.heading),
h("strong", { class: "assistant-dispatched-controls-count" }, String(state.commands.length)),
h("i", {
class: "assistant-dispatched-controls-chevron",
"data-lucide": "chevron-down",
}),
]),
h("ol", { class: "assistant-dispatched-controls-list", hidden: state.hideCommands }, state.commands.map(renderCommandRow)),
h("div", {
class: "assistant-dispatched-controls-footer",
hidden: !state.footer,
ref(element) {
if (element && state.footer && state.footer.parentNode !== element) {
element.replaceChildren(state.footer);
}
},
}),
]);
},
});
app.mount(container);
const mount = { app, state };
commandMounts.set(container, mount);
return mount;
}
function renderDispatchedControls(container, commands, options = {}) {
if (!(container instanceof Element) || !Array.isArray(commands) || !commands.length) {
return false;
}
const mount = commandMounts.get(container) || createCommandMount(container);
replaceReactiveArray(mount.state.commands, commands);
mount.state.heading = text(options.heading, "下发控制指令");
container.dataset.vueSurface = "dispatched-controls";
refreshIcons();
return true;
}
function setDispatchedControlsFooter(container, footer, options = {}) {
const mount = commandMounts.get(container);
if (!mount || !(footer instanceof Element)) return false;
mount.state.footer = footer;
mount.state.hideCommands = Boolean(options.hideCommands);
refreshIcons();
return true;
}
document.documentElement.classList.add("vue-presentation-enabled");
document.documentElement.dataset.vueVersion = VUE_VERSION;
window.CognitiveAgentVueUI = Object.freeze({
available: true,
version: VUE_VERSION,
renderDispatchedControls,
setDispatchedControlsFooter,
renderModeChips,
});
}());
+433
View File
@@ -0,0 +1,433 @@
{
"schema": "cognitive-agent-can-semantic-protocol/v1",
"version": "1.0.1",
"description": "Single source of truth for CAN0/CAN1 semantic commands and transport metadata. Frame encoders and feedback algorithms remain named special handlers when they require stateful closed-loop behavior.",
"channels": {
"0": {
"name": "passenger_seat_can",
"frame_format": "classic_can",
"bitrate": 500000
},
"1": {
"name": "vehicle_body_can",
"frame_format": "mixed",
"arbitration_bitrate": 500000,
"data_bitrate": 2000000
}
},
"rules": [
{
"id": "can0.seat.position",
"channel": 0,
"domain": "seat",
"capabilities": [
"seat.set_longitudinal_position",
"seat.set_cushion_tilt",
"seat.set_backrest_recline",
"seat.set_legrest_height"
],
"help": "seat passenger <horizontal|basin|backrest|legrest> position <0-100>",
"patterns": [
[
{"literal": "seat"},
{"literal": "passenger"},
{"enum": "axis", "values": ["horizontal", "longitudinal", "basin", "height", "backrest", "legrest"]},
{"literal": "position"},
{"integer": "position_pct", "minimum": 0, "maximum": 100}
]
],
"frames": [
{
"key": "move",
"frame_id": "0x300",
"format": "classic_can",
"brs": false,
"dlc": 8,
"template_kind": "can0_motion_fields",
"base_payload_hex": "00 10 00 00 00 00 00 00",
"fields": {
"opcode": {"start_bit": 0, "bit_length": 8, "absolute": 1, "home_zero": 11},
"motor": {"start_bit": 8, "bit_length": 2, "values": {"horizontal": 0, "longitudinal": 0, "basin": 1, "height": 1, "backrest": 2, "legrest": 3}},
"protocol_version": {"start_bit": 12, "bit_length": 2, "constant": 1},
"sequence": {"start_bit": 16, "bit_length": 16, "runtime_allocated": true},
"param_a": {"start_bit": 32, "bit_length": 16, "scale": 100, "home_key": 19282},
"param_b": {"start_bit": 48, "bit_length": 16, "absolute_timeout_ms": 30000, "home_timeout_s": 120}
},
"repeat": {"kind": "fixed", "count": 1, "period_ms": 0},
"feedback_ids": ["0x301", "0x302"],
"feedback_policy": "ack_then_motor_terminal"
}
],
"special_handler": "can0_absolute_position",
"evidence": "passenger_firmware_hil_verified"
},
{
"id": "can0.seat.relative",
"channel": 0,
"domain": "seat",
"capabilities": [
"seat.set_longitudinal_position",
"seat.set_cushion_tilt",
"seat.set_backrest_recline",
"seat.set_legrest_height"
],
"help": "seat passenger <axis> relative <direction> <1-100>",
"patterns": [
[{"literal": "seat"}, {"literal": "passenger"}, {"enum": "axis", "values": ["horizontal", "longitudinal"]}, {"literal": "relative"}, {"enum": "direction", "values": ["forward", "backward"]}, {"integer": "delta_pct", "minimum": 1, "maximum": 100}],
[{"literal": "seat"}, {"literal": "passenger"}, {"enum": "axis", "values": ["basin", "height"]}, {"literal": "relative"}, {"enum": "direction", "values": ["up", "down"]}, {"integer": "delta_pct", "minimum": 1, "maximum": 100}],
[{"literal": "seat"}, {"literal": "passenger"}, {"literal": "backrest"}, {"literal": "relative"}, {"enum": "direction", "values": ["forward", "backward"]}, {"integer": "delta_pct", "minimum": 1, "maximum": 100}],
[{"literal": "seat"}, {"literal": "passenger"}, {"literal": "legrest"}, {"literal": "relative"}, {"enum": "direction", "values": ["up", "down"]}, {"integer": "delta_pct", "minimum": 1, "maximum": 100}]
],
"frames": [
{
"key": "move",
"frame_id": "0x300",
"format": "classic_can",
"brs": false,
"dlc": 8,
"template_kind": "can0_relative_closed_loop_or_timed_fallback",
"base_payload_hex": "03 10 00 00 00 00 00 00",
"fields": {
"closed_loop_opcode": 1,
"timed_fallback_opcode": 3,
"timed_fallback_max_delta_pct": 15,
"direction_values": {"increase": 1, "decrease": 2}
},
"repeat": {"kind": "fixed", "count": 1, "period_ms": 0},
"feedback_ids": ["0x301", "0x302"],
"feedback_policy": "fresh_position_then_ack_then_motor_terminal"
}
],
"special_handler": "can0_relative_position",
"evidence": "passenger_firmware_hil_verified"
},
{
"id": "can0.seat.thermal",
"channel": 0,
"domain": "seat",
"capabilities": ["seat.set_ventilation", "seat.set_heating"],
"help": "seat passenger <vent|heat> <off|low|medium|high>",
"patterns": [
[{"literal": "seat"}, {"literal": "passenger"}, {"enum": "feature", "values": ["vent", "heat"]}, {"enum": "level", "values": ["off", "low", "medium", "high"]}]
],
"frames": [
{
"key": "thermal_mode",
"frame_id": "0x3CE",
"format": "classic_can",
"brs": false,
"dlc": 8,
"template_kind": "can0_heating_mode_optional",
"base_payload_hex": "10 00 00 00 00 00 00 00",
"repeat": {"kind": "fixed", "count": 1, "period_ms": 0},
"feedback_ids": ["0x4FA"],
"optional_when": "heating_on"
},
{
"key": "thermal_control",
"frame_id": "0x29E",
"format": "classic_can",
"brs": false,
"dlc": 8,
"template_kind": "can0_thermal_bit_fields",
"base_payload_hex": "00 00 00 00 00 00 00 00",
"repeat": {"kind": "fixed", "count": 1, "period_ms": 0},
"feedback_ids": ["0x4FA"],
"feedback_policy": "thermal_state_match"
}
],
"special_handler": "can0_thermal_preserve_peer_feature",
"evidence": "passenger_firmware_hil_verified"
},
{
"id": "can0.seat.dynamic",
"channel": 0,
"domain": "seat",
"capabilities": ["seat.start_dynamic_mode", "seat.stop_dynamic_mode"],
"help": "seat passenger dynamic <rocking-horse|cradle|stop>",
"patterns": [
[{"literal": "seat"}, {"literal": "passenger"}, {"literal": "dynamic"}, {"enum": "mode", "values": ["rocking-horse", "cradle", "stop"]}],
[{"literal": "seat"}, {"literal": "passenger"}, {"literal": "dynamic"}, {"literal": "start"}, {"enum": "mode", "values": ["rocking-horse", "cradle"]}]
],
"frames": [
{
"key": "dynamic",
"frame_id": "0x300",
"format": "classic_can",
"brs": false,
"dlc": 8,
"template_kind": "can0_dynamic_fields",
"base_payload_hex": "0E 10 00 00 00 00 00 00",
"fields": {"start_opcode": 14, "stop_opcode": 15, "mode_values": {"rocking-horse": 1, "cradle": 2, "stop": 0}},
"repeat": {"kind": "fixed", "count": 1, "period_ms": 0},
"feedback_ids": ["0x301", "0x306"],
"feedback_policy": "ack_then_dynamic_state"
}
],
"special_handler": "can0_dynamic_mode",
"evidence": "passenger_firmware_hil_verified"
},
{
"id": "can1.door.rear_lock",
"channel": 1,
"domain": "door",
"capabilities": ["door.set_rear_lock"],
"help": "door rear <lock|unlock>",
"patterns": [[{"literal": "door"}, {"literal": "rear"}, {"enum": "state", "values": ["lock", "unlock"]}]],
"frames": [{"key": "command", "frame_id": "0x32B", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "0A 00 00 00 00 00 00 00", "fields": {"state": {"start_bit": 14, "bit_length": 2, "values": {"lock": 1, "unlock": 2}}}, "repeat": {"kind": "fixed", "count": 1, "period_ms": 0}, "feedback_ids": ["0x407"], "feedback_policy": "rear_lock_state_match"}],
"evidence": "matrix_candidate_with_feedback"
},
{
"id": "can1.window.motion",
"channel": 1,
"domain": "window",
"capabilities": ["window.set_motion"],
"help": "window <all|front|rear|front-left|front-right|rear-left|rear-right> <open|close|stop>",
"patterns": [[{"literal": "window"}, {"enum": "target", "values": ["all", "front", "rear", "front-left", "front-right", "rear-left", "rear-right"]}, {"enum": "motion", "values": ["open", "close", "stop"]}]],
"frames": [{"key": "command", "frame_id": "0x431", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "window_motion_bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"starts": [48, 56, 51, 59], "motion_values": {"open": 1, "close": 2, "stop": 3}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x388"], "feedback_policy": "window_state_match"}],
"evidence": "vehicle_trace_verified"
},
{
"id": "can1.window.position",
"channel": 1,
"domain": "window",
"capabilities": ["window.set_position"],
"help": "window <front-left|front-right|rear-left|rear-right> position <0-100>",
"patterns": [[{"literal": "window"}, {"enum": "target", "values": ["front-left", "front-right", "rear-left", "rear-right"]}, {"literal": "position"}, {"integer": "position_pct", "minimum": 0, "maximum": 100}]],
"frames": [{"key": "command", "frame_id": "0x431", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "window_feedback_closed_loop", "base_payload_hex": "00 00 00 00 00 00 00 00", "repeat": {"kind": "special", "count": 1, "period_ms": 0}, "feedback_ids": ["0x388"], "feedback_policy": "fresh_position_then_motion_then_stop"}],
"special_handler": "window_position"
},
{
"id": "can1.climate.power",
"channel": 1,
"domain": "climate",
"capabilities": ["climate.set_power"],
"help": "ac <on|off>",
"patterns": [[{"literal": "ac"}, {"enum": "state", "values": ["on", "off"]}]],
"frames": [{"key": "command", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"state": {"start_bit": 28, "bit_length": 2, "values": {"on": 1, "off": 2}}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_power_match"}],
"evidence": "matrix_and_vehicle_trace_verified"
},
{
"id": "can1.climate.fan",
"channel": 1,
"domain": "climate",
"capabilities": ["climate.set_fan_level"],
"help": "ac fan <1-7>",
"patterns": [[{"literal": "ac"}, {"literal": "fan"}, {"integer": "level", "minimum": 1, "maximum": 7}]],
"frames": [{"key": "command", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"level": {"start_bit": 4, "bit_length": 4}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_fan_match"}],
"evidence": "matrix_and_vehicle_trace_verified"
},
{
"id": "can1.climate.mode",
"channel": 1,
"domain": "climate",
"capabilities": ["climate.set_outlet_mode"],
"help": "ac mode <face|face-feet|feet|feet-defrost|defrost|face-feet-defrost|face-defrost>",
"patterns": [[{"literal": "ac"}, {"literal": "mode"}, {"enum": "mode", "values": ["face", "face-feet", "feet", "feet-defrost", "defrost", "face-feet-defrost", "face-defrost"]}]],
"frames": [{"key": "command", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"mode": {"start_bit": 0, "bit_length": 4, "values": {"face": 1, "face-feet": 2, "feet": 3, "feet-defrost": 4, "defrost": 5, "face-feet-defrost": 6, "face-defrost": 7}}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_mode_match"}],
"evidence": "matrix_and_vehicle_trace_verified"
},
{
"id": "can1.climate.temperature",
"channel": 1,
"domain": "climate",
"capabilities": ["climate.set_temperature"],
"help": "ac temp <driver|passenger|rear> <18-32>",
"patterns": [[{"literal": "ac"}, {"literal": "temp"}, {"enum": "target", "values": ["driver", "passenger", "rear"]}, {"integer": "temperature_c", "minimum": 18, "maximum": 32}]],
"frames": [
{"key": "front", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "climate_temperature", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"starts": {"driver": 32, "passenger": 40}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_temperature_match"},
{"key": "rear", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "climate_temperature", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"starts": {"rear": 48}}, "repeat": {"kind": "fixed", "count": 1, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_temperature_match"}
],
"evidence": "matrix_and_vehicle_trace_verified"
},
{
"id": "can1.climate.control",
"channel": 1,
"domain": "climate",
"capabilities": ["climate.set_control_mode"],
"help": "ac control <manual|auto>",
"patterns": [[{"literal": "ac"}, {"literal": "control"}, {"enum": "mode", "values": ["manual", "auto"]}]],
"frames": [{"key": "command", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"mode": {"start_bit": 16, "bit_length": 2, "values": {"manual": 1, "auto": 2}}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_control_match"}],
"evidence": "matrix_and_vehicle_trace_verified"
},
{
"id": "can1.climate.cycle",
"channel": 1,
"domain": "climate",
"capabilities": ["climate.set_cycle_mode"],
"help": "ac cycle <internal|external>",
"patterns": [[{"literal": "ac"}, {"literal": "cycle"}, {"enum": "mode", "values": ["internal", "external"]}]],
"frames": [{"key": "command", "frame_id": "0x1DE", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "bit_fields", "base_payload_hex": "00 00 00 00 00 00 00 00", "fields": {"mode": {"start_bit": 19, "bit_length": 2, "values": {"internal": 1, "external": 2}}}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x404"], "feedback_policy": "climate_cycle_match"}],
"evidence": "matrix_and_vehicle_trace_verified"
},
{
"id": "can1.outlet.switch",
"channel": 1,
"domain": "outlet",
"capabilities": ["outlet.set_switch_state"],
"help": "outlet <driver-left|driver-right|passenger-left|passenger-right> <open|close>",
"patterns": [[{"literal": "outlet"}, {"enum": "target", "values": ["driver-left", "driver-right", "passenger-left", "passenger-right"]}, {"enum": "state", "values": ["open", "close"]}]],
"frames": [{
"key": "command", "frame_id": "0x4EF", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "static_variant", "base_payload_hex": "00 00 00 00 00 00 00 00",
"payload_variants": {
"driver-left:open": "02 FF FF FF FF 01 FF FF",
"driver-left:close": "02 FF FF FF FF 02 FF FF",
"driver-right:open": "02 FF FF FF FF 04 FF FF",
"driver-right:close": "02 FF FF FF FF 08 FF FF",
"passenger-left:open": "03 FF FF FF FF 01 00 00",
"passenger-left:close": "03 FF FF FF FF 02 00 00",
"passenger-right:open": "03 FF FF FF FF 04 00 00",
"passenger-right:close": "03 FF FF FF FF 08 00 00"
},
"repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x4BB", "0x37B"], "feedback_policy": "outlet_switch_state_match"
}],
"evidence": "vehicle_differential_trace_verified"
},
{
"id": "can1.outlet.percent",
"channel": 1,
"domain": "outlet",
"capabilities": ["outlet.set_position_percent"],
"help": "outlet <target> percent <0-100> <0-100>",
"patterns": [[{"literal": "outlet"}, {"enum": "target", "values": ["driver-left", "driver-right", "passenger-left", "passenger-right"]}, {"literal": "percent"}, {"integer": "horizontal_pct", "minimum": 0, "maximum": 100}, {"integer": "vertical_pct", "minimum": 0, "maximum": 100}]],
"frames": [{"key": "command", "frame_id": "0x4EF", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "outlet_calibrated_coordinates", "base_payload_hex": "00 FF FF FF FF 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x2F5", "0x37B"], "feedback_policy": "outlet_position_match"}],
"special_handler": "outlet_percent_to_calibrated_position"
},
{
"id": "can1.outlet.position",
"channel": 1,
"domain": "outlet",
"capabilities": ["outlet.set_calibrated_position"],
"help": "outlet <target> position <-128..126> <-128..126>",
"patterns": [[{"literal": "outlet"}, {"enum": "target", "values": ["driver-left", "driver-right", "passenger-left", "passenger-right"]}, {"literal": "position"}, {"integer": "horizontal_value", "minimum": -128, "maximum": 126}, {"integer": "vertical_value", "minimum": -128, "maximum": 126}]],
"frames": [{"key": "command", "frame_id": "0x4EF", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "outlet_calibrated_coordinates", "base_payload_hex": "00 FF FF FF FF 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x2F5", "0x37B"], "feedback_policy": "outlet_position_match"}],
"special_handler": "outlet_calibrated_position"
},
{
"id": "can1.seat.driver_thermal",
"channel": 1,
"domain": "seat",
"capabilities": ["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"],
"help": "seat driver <vent|heat> <off|low|high>",
"patterns": [[{"literal": "seat"}, {"literal": "driver"}, {"enum": "feature", "values": ["vent", "heat"]}, {"enum": "level", "values": ["off", "low", "high"]}]],
"frames": [{"key": "command", "frame_id": "0x431", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "driver_seat_thermal", "base_payload_hex": "00 00 00 00 00 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x4FA"], "feedback_policy": "driver_seat_thermal_match"}],
"evidence": "vehicle_trace_verified"
},
{
"id": "can1.seat.driver_massage",
"channel": 1,
"domain": "seat",
"capabilities": ["seat.set_driver_massage_native"],
"help": "seat driver massage <off|strength weak|medium|strong|mode wave|pulse|stretch|slack|relax>",
"patterns": [
[{"literal": "seat"}, {"literal": "driver"}, {"literal": "massage"}, {"literal": "off"}],
[{"literal": "seat"}, {"literal": "driver"}, {"literal": "massage"}, {"literal": "strength"}, {"enum": "strength", "values": ["weak", "medium", "strong"]}],
[{"literal": "seat"}, {"literal": "driver"}, {"literal": "massage"}, {"literal": "mode"}, {"enum": "mode", "values": ["wave", "pulse", "stretch", "slack", "relax"]}]
],
"frames": [{"key": "command", "frame_id": "0x4C1", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "driver_seat_massage", "base_payload_hex": "12 00 00 00 00 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x4FA"], "feedback_policy": "driver_seat_massage_match"}],
"evidence": "vehicle_trace_verified"
},
{
"id": "can1.seat.driver_axis",
"channel": 1,
"domain": "seat",
"capabilities": ["seat.set_driver_axis_native"],
"help": "seat driver <horizontal|height|backrest|basin> <direction|stop> [100-2000]",
"patterns": [
[{"literal": "seat"}, {"literal": "driver"}, {"enum": "axis", "values": ["horizontal", "backrest"]}, {"enum": "direction", "values": ["forward", "backward"]}],
[{"literal": "seat"}, {"literal": "driver"}, {"enum": "axis", "values": ["horizontal", "backrest"]}, {"enum": "direction", "values": ["forward", "backward"]}, {"integer": "duration_ms", "minimum": 100, "maximum": 2000}],
[{"literal": "seat"}, {"literal": "driver"}, {"enum": "axis", "values": ["height", "basin"]}, {"enum": "direction", "values": ["up", "down"]}],
[{"literal": "seat"}, {"literal": "driver"}, {"enum": "axis", "values": ["height", "basin"]}, {"enum": "direction", "values": ["up", "down"]}, {"integer": "duration_ms", "minimum": 100, "maximum": 2000}],
[{"literal": "seat"}, {"literal": "driver"}, {"enum": "axis", "values": ["horizontal", "height", "backrest", "basin"]}, {"literal": "stop"}]
],
"frames": [
{"key": "motion", "frame_id": "0x4C1", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "driver_seat_axis", "base_payload_hex": "05 00 00 00 00 00 00 00", "repeat": {"kind": "duration_ceiling", "count": 0, "period_ms": 100, "default_duration_ms": 500}, "feedback_ids": ["0x494"], "feedback_policy": "activity_then_forced_stop"},
{"key": "stop", "frame_id": "0x4C1", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "static", "base_payload_hex": "05 00 00 00 00 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x494"], "feedback_policy": "submitted_stop"}
],
"special_handler": "driver_seat_axis"
},
{
"id": "can1.seat.driver_memory",
"channel": 1,
"domain": "seat",
"capabilities": ["seat.set_driver_position_native", "seat.recall_driver_memory_native"],
"help": "seat driver position <1-3> | seat driver memory recall <1-3>",
"patterns": [
[{"literal": "seat"}, {"literal": "driver"}, {"literal": "position"}, {"integer": "slot", "minimum": 1, "maximum": 3}],
[{"literal": "seat"}, {"literal": "driver"}, {"literal": "memory"}, {"literal": "recall"}, {"integer": "slot", "minimum": 1, "maximum": 3}]
],
"frames": [{"key": "sequence", "frame_id": "0x4C1", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "learned_driver_memory_pair", "base_payload_hex": "16 00 00 00 00 00 00 00", "repeat": {"kind": "sequence", "count": 20, "period_ms": 50}, "feedback_ids": ["0x494"], "feedback_policy": "fresh_initialized_status_then_post_status"}],
"special_handler": "driver_seat_memory_recall"
},
{
"id": "can1.light.indoor",
"channel": 1,
"domain": "light",
"capabilities": ["interior_light.set_indoor_power"],
"help": "light indoor <on|off>",
"patterns": [[{"literal": "light"}, {"literal": "indoor"}, {"enum": "state", "values": ["on", "off"]}]],
"frames": [{"key": "command", "frame_id": "0x4F5", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "reading_light_all", "base_payload_hex": "00 00 03 FF FF 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x42E"], "feedback_policy": "indoor_light_match"}],
"evidence": "vehicle_trace_verified"
},
{
"id": "can1.light.reading_all",
"channel": 1,
"domain": "light",
"capabilities": ["interior_light.set_reading_power"],
"help": "light reading all <on|off>",
"patterns": [[{"literal": "light"}, {"literal": "reading"}, {"literal": "all"}, {"enum": "state", "values": ["on", "off"]}]],
"frames": [{"key": "command", "frame_id": "0x4F5", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "reading_light_all", "base_payload_hex": "00 00 03 FF FF 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x42E"], "feedback_policy": "indoor_light_match"}],
"evidence": "vehicle_trace_verified"
},
{
"id": "can1.light.ambient_power",
"channel": 1,
"domain": "light",
"capabilities": ["ambient_light.set_power"],
"help": "light ambient <on|off>",
"patterns": [[{"literal": "light"}, {"literal": "ambient"}, {"enum": "state", "values": ["on", "off"]}]],
"frames": [{"key": "command", "frame_id": "0x3FC", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "static_variant", "base_payload_hex": "00 00 00 00 00 00 00 00", "payload_variants": {"on": "00 36 00 75 00 80 00 00", "off": "00 31 00 75 00 80 00 00"}, "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x42E"], "feedback_policy": "ambient_power_match"}],
"evidence": "vehicle_trace_verified"
},
{
"id": "can1.light.ambient_color",
"channel": 1,
"domain": "light",
"capabilities": ["ambient_light.set_color_index"],
"help": "light ambient color <1-127>",
"patterns": [[{"literal": "light"}, {"literal": "ambient"}, {"literal": "color"}, {"integer": "color_index", "minimum": 1, "maximum": 127}]],
"frames": [{"key": "command", "frame_id": "0x3FC", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "ambient_color", "base_payload_hex": "00 30 00 75 00 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x42E"], "feedback_policy": "ambient_color_match"}],
"evidence": "vehicle_trace_verified"
},
{
"id": "can1.light.ambient_brightness",
"channel": 1,
"domain": "light",
"capabilities": ["ambient_light.set_brightness_level"],
"help": "light ambient brightness <1-5>",
"patterns": [[{"literal": "light"}, {"literal": "ambient"}, {"literal": "brightness"}, {"integer": "brightness_level", "minimum": 1, "maximum": 5}]],
"frames": [{"key": "command", "frame_id": "0x3FC", "format": "can_fd", "brs": true, "dlc": 8, "template_kind": "ambient_brightness", "base_payload_hex": "00 30 00 75 00 00 00 00", "repeat": {"kind": "fixed", "count": 3, "period_ms": 100}, "feedback_ids": ["0x42E"], "feedback_policy": "ambient_brightness_match"}],
"evidence": "vehicle_trace_verified"
},
{
"id": "can1.shade.motion",
"channel": 1,
"domain": "roof",
"capabilities": ["roof.set_shade_motion"],
"help": "shade <open|close|half|stop>",
"patterns": [[{"literal": "shade"}, {"enum": "motion", "values": ["open", "close", "half", "stop"]}]],
"frames": [{"key": "command", "frame_id": "0x431", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "static_variant", "base_payload_hex": "00 00 00 00 00 00 00 00", "payload_variants": {"open": "10 00 00 00 00 00 00 00", "close": "20 00 00 00 00 00 00 00", "half": "30 00 00 00 00 00 00 00", "stop": "40 00 00 00 00 00 00 00"}, "repeat": {"kind": "fixed", "count": 1, "period_ms": 0}, "feedback_ids": ["0x41A"], "feedback_policy": "shade_position_or_stop_submission"}],
"special_handler": "shade_preset"
},
{
"id": "can1.shade.position",
"channel": 1,
"domain": "roof",
"capabilities": ["roof.set_shade_position"],
"help": "shade position <0-100>",
"patterns": [[{"literal": "shade"}, {"literal": "position"}, {"integer": "position_pct", "minimum": 0, "maximum": 100}]],
"frames": [{"key": "command", "frame_id": "0x431", "format": "classic_can", "brs": false, "dlc": 8, "template_kind": "shade_feedback_closed_loop", "base_payload_hex": "40 00 00 00 00 00 00 00", "repeat": {"kind": "special", "count": 1, "period_ms": 0}, "feedback_ids": ["0x41A"], "feedback_policy": "fresh_position_then_motion_then_stop"}],
"special_handler": "shade_position"
}
]
}
@@ -23,3 +23,20 @@ COGNITIVE_AGENT_VEHICLE_MQTT_PORT=1883
COGNITIVE_AGENT_VEHICLE_MQTT_CONDITION_TOPIC=n7/n7-headunit/can/state
COGNITIVE_AGENT_VEHICLE_MQTT_CONTROL_TOPIC=n7/n7-headunit/can/command
COGNITIVE_AGENT_VEHICLE_MQTT_QOS=1
# GSCU1B infrared module. Linux acts as the TCP server and the module connects
# as a TCP client. The same listen address and port can also be changed from
# the Web Console and are persisted in artifacts/tmp/gscu_gateway.json.
GSCU_TRANSPORT=tcp
GSCU_TCP_LISTEN_HOST=0.0.0.0
GSCU_TCP_PORT=4444
# Optional; defaults to artifacts/server/gscu_ir_catalog.sqlite3.
# GSCU_IR_CATALOG_DB_PATH=/home/orin/project511/Cognitive_Agent/artifacts/server/gscu_ir_catalog.sqlite3
# MQTT remains available as an optional fallback, but is not the default.
# GSCU_TRANSPORT=mqtt
# GSCU_MQTT_HOST=127.0.0.1
# GSCU_MQTT_PORT=1883
# GSCU_COMMAND_TOPIC=topic/from_n7
# GSCU_RESPONSE_TOPIC=topic/from_infrared
# GSCU_MQTT_QOS=0
@@ -0,0 +1,24 @@
# Linux USBCANFD-200U wired dual-channel N7 runtime.
COGNITIVE_AGENT_VEHICLE_CAN_ENABLED=true
COGNITIVE_AGENT_VEHICLE_CAN_BACKEND=zlgcan-linux
COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL=1
COGNITIVE_AGENT_VEHICLE_CAN_REQUIRED_CHANNEL=1
COGNITIVE_AGENT_VEHICLE_CAN_ARBITRATION_BITRATE=500000
COGNITIVE_AGENT_VEHICLE_CAN_DATA_BITRATE=2000000
COGNITIVE_AGENT_VEHICLE_CAN_TERMINATION_ENABLED=false
COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED=true
COGNITIVE_AGENT_SEAT_CAN_BACKEND=zlgcan-linux
COGNITIVE_AGENT_SEAT_CAN_CHANNEL=0
COGNITIVE_AGENT_SEAT_CAN_BITRATE=500000
COGNITIVE_AGENT_SEAT_CAN_TERMINATION_ENABLED=false
COGNITIVE_AGENT_SEAT_AUTO_FLASH_ENABLED=false
COGNITIVE_AGENT_SEAT_POSITION_SYNC_ENABLED=false
COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE=33
COGNITIVE_AGENT_ZLGCAN_DEVICE_INDEX=0
COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY=/home/orin/project511/project-code/canfd/libusbcanfd.so
COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED=false
# Local supervised bench only. Keep token approval enabled for shared/remote use.
COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED=false
@@ -0,0 +1,12 @@
# Remote NanoMQ broker exposed by Caddy as MQTT 3.1.1 over WebSocket Secure.
# Copy these values into the repository-root .env file and replace the
# credential placeholders with an account configured on the broker. Never
# commit the real password.
COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED=true
COGNITIVE_AGENT_VEHICLE_MQTT_HOST=511.kendeji.fun
COGNITIVE_AGENT_VEHICLE_MQTT_PORT=443
COGNITIVE_AGENT_VEHICLE_MQTT_TRANSPORT=websockets
COGNITIVE_AGENT_VEHICLE_MQTT_WEBSOCKET_PATH=/mqtt
COGNITIVE_AGENT_VEHICLE_MQTT_TLS=true
COGNITIVE_AGENT_VEHICLE_MQTT_USERNAME=<broker-username>
COGNITIVE_AGENT_VEHICLE_MQTT_PASSWORD=<broker-password>
@@ -2,12 +2,13 @@ id: leisure_lounger
name: 休闲躺椅模式
aliases:
- 休闲躺椅
description: 腾势 N7 副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力。
description: 腾势 N7 主驾放松按摩与副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力;驻车使用,不改变主驾驾驶姿态。
icon: sofa
scope:
vehicle_ids:
- denza_n7
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用已接入的主驾放松按摩,让主副驾按各自能力联动休闲;主驾没有绝对位置反馈,不自动移动其座椅。}
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
@@ -3,12 +3,13 @@ name: 冥想空间模式
aliases:
- 冥想空间
- 冥想
description: 腾势 N7 冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力。
description: 腾势 N7 主驾放松按摩与副驾冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力;驻车使用,不改变主驾驾驶姿态。
icon: leaf
scope:
vehicle_ids:
- denza_n7
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用当前工程已接入的主驾放松按摩辅助驻车冥想;不假定副驾具有按摩能力,不移动主驾座椅。}
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
+2 -1
View File
@@ -2,12 +2,13 @@ id: nap
name: 小憩模式
aliases:
- 小憩
description: 腾势 N7 副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力。
description: 腾势 N7 主驾低档座椅通风与副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力;驻车使用,不改变主驾驾驶姿态。
icon: moon
scope:
vehicle_ids:
- denza_n7
operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾使用低档座椅通风保持驻车静坐舒适,不自动移动主驾或开启按摩。}
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
+5 -3
View File
@@ -2,12 +2,14 @@ id: office
name: 办公模式
aliases:
- 办公
description: 腾势 N7 副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力。
description: 腾势 N7 主副驾低档座椅通风与副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力;不改变主驾驾驶姿态。
icon: briefcase
scope:
vehicle_ids:
- denza_n7
operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 低档通风,改善驻车办公时久坐闷热;不移动主驾座椅。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口低档通风,与办公支撑姿态联动。}
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
@@ -43,8 +45,8 @@ operations:
target_scope: passenger
operation: set
parameters:
temperature_c: 24.5
reason: 办公座位专属恒温 24.5 度。
temperature_c: 25
reason: 办公座位专属恒温 25 度,使用当前 CAN 协议支持的整数温度。
- domain: climate
capability: climate.set_fan_level
target_scope: vehicle
@@ -44,8 +44,8 @@ operations:
target_scope: passenger
operation: set
parameters:
temperature_c: 23.5
reason: 副驾恒温锁定 23.5 度。
temperature_c: 24
reason: 副驾恒温锁定 24 度,使用当前 CAN 协议支持的整数温度。
- domain: climate
capability: climate.set_fan_level
target_scope: vehicle
@@ -18,7 +18,7 @@ spec:
- name: state
type: string
required: true
allowed_values: [on, off]
allowed_values: ["on", "off"]
description: Absolute ventilation state.
- name: level
type: integer
@@ -41,7 +41,7 @@ spec:
- name: state
type: string
required: true
allowed_values: [on, off]
allowed_values: ["on", "off"]
description: Absolute heating state.
- name: level
type: integer
@@ -64,7 +64,7 @@ spec:
- name: action
type: string
required: true
allowed_values: [off, strength, mode]
allowed_values: ["off", strength, mode]
description: Explicit massage operation; a generic on request is not accepted.
- name: strength
type: integer
@@ -7,6 +7,18 @@ metadata:
spec:
domain: outlet
capabilities:
- name: outlet.set_switch_state
description: Open or close one front motorized air outlet using the byte-exact head-unit CAN FD request observed on the vehicle.
target_devices: [motorized_air_outlet]
target_scopes: [driver_left, driver_right, passenger_left, passenger_right]
parameters:
- name: state
type: string
required: true
allowed_values: [open, close]
description: Explicit switch state for exactly one air outlet.
risk_level: medium
confirmation_policy: on_ambiguity
- name: outlet.set_position_percent
description: Set both axes of one calibrated front air outlet using normalized percentages through vehicle CAN FD.
target_devices: [motorized_air_outlet]
@@ -0,0 +1,26 @@
id: boarding-warmup
name: 上车预热模式
aliases: [上车预热的功能模式, 上车预热功能模式, 上车暖舱模式, 分阶段预热模式, 动态预热模式, 空调预热模式]
description: 分阶段上车预热;前2分钟以28℃和4档风量快速暖舱,再用24℃和1档风量舒适保持5分钟,并分别编排主副驾加热。总计7分钟,不移动座椅位置;时间轴结束后保持最后设置,不承诺无人值守预约启动。
icon: thermometer
schedule:
schema: cognitive-agent-mode-schedule/v1
repeat_count: 1
phases:
- id: warmup
name: 快速暖舱
duration_ms: 120000
operations:
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: 'on'}, reason: 开启空调。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 28}, reason: 初始预热目标。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 4}, reason: 初始阶段较高风量。}
- {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: 'on', level: 2}, reason: 主驾低档加热接口2为低档。}
- {domain: seat, capability: seat.set_heating, target_scope: passenger, operation: set, parameters: {state: 'on', level: 2}, reason: 副驾使用独立接口2档加热。}
- id: comfort
name: 舒适恒温
duration_ms: 300000
operations:
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 预热结束后降低目标温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 降低风噪。}
- {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: 'on', level: 2}, reason: 主驾维持低档。}
- {domain: seat, capability: seat.set_heating, target_scope: passenger, operation: set, parameters: {state: 'on', level: 1}, reason: 副驾降低为1档。}
+16
View File
@@ -0,0 +1,16 @@
id: camping
name: 露营模式
aliases: [户外露营模式, 营地模式]
description: 为驻车露营组合主副驾低档通风、副驾靠背和腿托支撑、低风外循环、开阔天幕与露营氛围;不移动主驾座椅,不自动开窗。
icon: tent-tree
operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风,减少驻车久坐闷热。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口的低档通风。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 为驻车露营提供适度舒展的副驾靠背支撑。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 35}, reason: 使用适度副驾腿托支撑,不采用极限躺平姿态。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持座舱温度。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。}
- {domain: climate, capability: climate.set_cycle_mode, target_scope: vehicle, operation: set, parameters: {mode: external}, reason: 使用外循环换气。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 100}, reason: 打开遮阳帘获得开阔视野。}
- {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: camping}, reason: 使用工程已接入的露营氛围。}
+17
View File
@@ -0,0 +1,17 @@
id: cinema
name: 观影模式
aliases: [影院模式, 家庭影院模式, 电影模式, 看电影模式]
description: 为驻车观影组合主驾放松按摩、副驾舒展靠背和腿托、低档通风、遮光与低风恒温;不改变主驾驾驶姿态,不包含屏幕或音响控制。
icon: film
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用主驾已接入的放松按摩,缓解驻车观影时的久坐不适;不移动主驾座椅。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾低档通风减少久坐闷热。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 55}, reason: 为驻车观影提供适度后仰的副驾靠背支撑。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 为驻车观影提供副驾腿部支撑。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 维持观影期间的座舱恒温。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用低风量降低噪声。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 0}, reason: 关闭遮阳帘减少环境光。}
- {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: cinema}, reason: 使用工程已接入的影院氛围。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。}
- {domain: interior_light, capability: interior_light.set_reading_power, target_scope: all, operation: set, parameters: {state: "off"}, reason: 关闭全部已接入阅读灯。}
+10
View File
@@ -0,0 +1,10 @@
id: fresh-air
name: 外循环通风模式
aliases: [通风换气模式, 外循环模式]
description: 使用较高风量和外循环进行座舱换气;不宣称空气净化能力。
icon: wind
operations:
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调通风链路。}
- {domain: climate, capability: climate.set_cycle_mode, target_scope: vehicle, operation: set, parameters: {mode: external}, reason: 使用外循环换气。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 5}, reason: 使用较高风量换气。}
- {domain: climate, capability: climate.set_outlet_mode, target_scope: vehicle, operation: set, parameters: {mode: face_feet}, reason: 面脚均匀送风。}
+19
View File
@@ -0,0 +1,19 @@
id: lay_flat
name: 躺平模式
aliases:
- 躺平休息模式
- 平躺休息模式
description: 驻车休息预览:默认仅联动副驾靠背与腿托,配合恒温、柔和氛围光、关闭阅读灯与四扇车窗;指定主驾时仅使用其已接入的按摩能力,不调整驾驶姿态。所有动作需确认后保存或试运行。
icon: armchair
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 主驾只使用已接入的舒适按摩,不改变驾驶姿态。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 66}, reason: 为驻车躺靠休息提供副驾靠背支撑,不使用机械极限位置。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 76}, reason: 副驾腿托支撑腿部,具体姿态可在预览后调整。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 休息期间保持座舱恒温。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 提供可调整的中性舒适温度。}
- {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#FFB347', brightness_pct: 20}, reason: 使用低亮暖色氛围光。}
- {domain: interior_light, capability: interior_light.set_reading_power, target_scope: all, operation: set, parameters: {state: "off"}, reason: 关闭阅读灯,减少躺靠休息时的直射光。}
- {domain: window, capability: window.set_position, target_scope: front_left, operation: set, parameters: {position_pct: 0}, reason: 关闭左前车窗,降低外部噪声。}
- {domain: window, capability: window.set_position, target_scope: front_right, operation: set, parameters: {position_pct: 0}, reason: 关闭右前车窗,降低外部噪声。}
- {domain: window, capability: window.set_position, target_scope: rear_left, operation: set, parameters: {position_pct: 0}, reason: 关闭左后车窗,降低外部噪声。}
- {domain: window, capability: window.set_position, target_scope: rear_right, operation: set, parameters: {position_pct: 0}, reason: 关闭右后车窗,降低外部噪声。}
@@ -0,0 +1,14 @@
id: long-trip-comfort
name: 长途舒适模式
aliases: [长途乘车模式, 高速舒适模式]
description: 为长途乘坐提供主副驾低档座椅通风、副驾恒温、柔和风量、外循环与适度遮光,不改变座椅驾驶姿态。
icon: route
operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风;该接口 2 为低档。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口的低档通风;该接口 1 为低档。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。}
- {domain: climate, capability: climate.set_temperature, target_scope: passenger, operation: set, parameters: {temperature_c: 24}, reason: 保持副驾舒适温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 2}, reason: 使用中低风量。}
- {domain: climate, capability: climate.set_cycle_mode, target_scope: vehicle, operation: set, parameters: {mode: external}, reason: 长途行驶使用外循环。}
- {domain: climate, capability: climate.set_outlet_mode, target_scope: vehicle, operation: set, parameters: {mode: face_feet}, reason: 座舱面脚均匀送风。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 30}, reason: 降低长途日晒。}
+18
View File
@@ -0,0 +1,18 @@
id: quiet-night
name: 夜间静享模式
aliases:
- 夜间模式
- 静夜模式
description: 使用主副驾低档座椅通风、低亮冷色氛围、关闭主照明与阅读灯,并以低风恒温保持安静;不改变座椅姿态。
icon: moon-star
operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风,降低夜间久坐闷热且不启动姿态调整或按摩。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用低档通风,保持座椅当前姿态。}
- {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启低亮氛围灯。}
- {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#102040', brightness_pct: 10}, reason: 使用低亮深蓝氛围。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。}
- {domain: interior_light, capability: interior_light.set_reading_power, target_scope: all, operation: set, parameters: {state: "off"}, reason: 关闭全部已接入阅读灯。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 0}, reason: 关闭遮阳帘降低外界干扰。}
+11
View File
@@ -0,0 +1,11 @@
id: rain-defrost
name: 雨天除雾模式
aliases: [前挡除雾模式, 雨雾模式]
description: 开启前挡除雾、外循环和较高风量,使用当前工程可控的空调能力改善视野。
icon: cloud-rain
operations:
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调除雾链路。}
- {domain: climate, capability: climate.set_front_defrost, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启前挡除雾。}
- {domain: climate, capability: climate.set_cycle_mode, target_scope: vehicle, operation: set, parameters: {mode: external}, reason: 使用外循环降低水汽。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 4}, reason: 提高除雾风量。}
- {domain: climate, capability: climate.set_outlet_mode, target_scope: vehicle, operation: set, parameters: {mode: defrost}, reason: 风向对准除雾区域。}
+19
View File
@@ -0,0 +1,19 @@
id: reading
name: 阅读模式
aliases:
- 阅读空间模式
- 看书模式
- 读书模式
description: 为驻车阅读提供主副驾低档座椅通风、副驾靠背与腿托支撑、暖色照明、低风恒温和适度遮光;不改变主驾驾驶姿态。
icon: book-open
operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾采用已接入原生 CAN 的低档通风,减少久坐闷热;不移动主驾座椅。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用独立接口的低档通风。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 22}, reason: 使用偏直立的副驾阅读靠背支撑,避免大幅躺倒。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 21}, reason: 副驾腿托适度支撑腿部,供驻车阅读预览使用。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持阅读期间的座舱恒温。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 30}, reason: 降低眩光并保留部分自然光。}
- {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#FFF2D5', brightness_pct: 60}, reason: 使用柔和暖色辅助照明。}
- {domain: interior_light, capability: interior_light.set_reading_power, target_scope: all, operation: set, parameters: {state: "on"}, reason: 打开当前工程已接入的全部阅读灯。}
+15
View File
@@ -0,0 +1,15 @@
id: reception
name: 接待模式
aliases: [贵宾模式, 迎宾接待模式]
description: 使用主副驾低档座椅通风、柔和暖光、车内照明、恒温和半开遮阳帘营造接待环境;不自动移动有人入座的座椅。
icon: handshake
operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风营造舒适接待环境,保持当前座椅位置。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用低档通风,不在迎宾入座时自动调整姿态。}
- {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。}
- {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#FFE2B8', brightness_pct: 50}, reason: 使用柔和暖色氛围光。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启室内主照明。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 2}, reason: 使用中低风量。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 50}, reason: 遮阳帘半开。}
+18
View File
@@ -0,0 +1,18 @@
id: relaxation
name: 放松模式
aliases: [休闲模式, 舒缓模式, 轻松模式, 放空模式, 坐着放松模式, 乘坐放松模式, 座椅放松模式, 座位放松模式]
description: 使用主驾放松按摩、舒展的副驾姿态、安静恒温、适度遮光和低亮暖光,营造适合驻车放松休息的座舱环境;不改变主驾驾驶姿态。
icon: sofa
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用已验证的主驾放松按摩模式;主驾没有绝对位置反馈,不自动移动主驾座椅。}
- {domain: seat, capability: seat.set_longitudinal_position, target_scope: passenger, operation: set, parameters: {position_pct: 51}, reason: 将副驾移动到兼顾腿部空间与安全的休闲位置。}
- {domain: seat, capability: seat.set_cushion_tilt, target_scope: passenger, operation: set, parameters: {position_pct: 30}, reason: 调整坐盆角度以提供稳定支撑。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 66}, reason: 将副驾靠背调整到舒展的休闲角度。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 76}, reason: 升起腿托以放松腿部。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调恒温链路。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用适合静坐休息的中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量减少打扰。}
- {domain: climate, capability: climate.set_cycle_mode, target_scope: vehicle, operation: set, parameters: {mode: external}, reason: 使用外循环保持座舱空气流通。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 0}, reason: 关闭遮阳帘减少外界强光干扰。}
- {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启低亮氛围照明。}
- {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#FFB347', brightness_pct: 20}, reason: 使用低亮暖光营造放松氛围。}
+13
View File
@@ -0,0 +1,13 @@
id: romantic
name: 浪漫模式
aliases: [约会模式, 浪漫氛围模式]
description: 使用主副驾低档座椅通风与浪漫氛围灯,关闭车内主照明、阅读灯和遮阳帘;保持当前座椅姿态。
icon: heart
operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风减少久坐闷热,不调整驾驶姿态。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾低档通风,保持用户当前的座椅姿态。}
- {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。}
- {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: romantic}, reason: 使用工程已接入的浪漫氛围。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。}
- {domain: interior_light, capability: interior_light.set_reading_power, target_scope: all, operation: set, parameters: {state: "off"}, reason: 关闭全部已接入阅读灯。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 0}, reason: 关闭遮阳帘形成私密环境。}
@@ -0,0 +1,17 @@
id: romantic-cinema
name: 浪漫影院模式
aliases: [浪漫观影模式, 浪漫电影模式, 约会影院模式]
description: 完整保留观影模式的座椅、温控、遮光和车内灯光功能,并在运行时确定性加入当前已授权的外设灯光控制。
icon: heart
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 完整保留影院模式的主驾放松按摩;不移动主驾座椅。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 完整保留影院模式的副驾低档通风。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 55}, reason: 完整保留影院模式的副驾靠背姿态。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 完整保留影院模式的副驾腿部支撑。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 完整保留影院模式的座舱恒温。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 完整保留影院模式的舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 完整保留影院模式的低噪声风量。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 0}, reason: 完整保留影院模式的遮光设置。}
- {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: cinema}, reason: 完整保留影院模式的车内影院氛围。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 完整保留影院模式的室内主照明设置。}
- {domain: interior_light, capability: interior_light.set_reading_power, target_scope: all, operation: set, parameters: {state: "off"}, reason: 完整保留影院模式的阅读灯设置。}
+14
View File
@@ -0,0 +1,14 @@
id: seat-comfort
name: 座椅舒适模式
aliases: [座位舒适模式, 乘坐舒适模式, 坐着舒服模式, 坐着舒适模式, 座椅舒服模式, 座位舒服模式]
description: 为主副驾组合低档通风、主驾放松按摩和副驾适度靠背及腿托支撑;按两座实际能力分别编排,主驾没有绝对位置反馈,不自动移动主驾座椅。适合驻车调整并预览后使用。
icon: armchair
operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾通风使用独立 CAN 接口的低档 2,减少背部闷热。}
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用主驾已验证的放松按摩模式,不假定副驾具有按摩能力。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾通风使用独立接口的低档 1。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 副驾靠背使用可反馈的适度舒展目标。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 35}, reason: 副驾腿托提供适度腿部支撑。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启座舱恒温。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 保持中性舒适温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 降低风噪和直吹打扰。}
+23
View File
@@ -0,0 +1,23 @@
id: seat-dynamic
name: 座椅动态舒缓模式
aliases: [座椅动态模式, 座椅的动态模式, 动态座椅模式, 座椅循环模式, 座椅交替放松模式, 动态放松模式]
description: 驻车使用的有界双座舒缓时间轴;主驾在放松和舒缓按摩间切换,副驾靠背与腿托小幅交替,30秒一个阶段、重复5次,总计5分钟。不自动移动主驾位置;停止后不再进入后续阶段。
icon: activity
schedule:
schema: cognitive-agent-mode-schedule/v1
repeat_count: 5
phases:
- id: relax
name: 舒展支撑
duration_ms: 30000
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 主驾使用原生放松按摩,不改变驾驶姿态。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 驻车时副驾适度舒展。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 25}, reason: 适度支撑腿部。}
- id: support
name: 轻柔回落
duration_ms: 30000
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: slack}, reason: 主驾切换舒缓按摩。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 40}, reason: 副驾小幅回到支撑姿态。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 20}, reason: 腿托小幅回落,不使用无界轴运动。}
@@ -0,0 +1,13 @@
id: summer-cooling
name: 夏季清凉模式
aliases: [清凉模式, 酷夏模式]
description: 使用内循环、适中冷风、关闭遮阳帘并分别开启主驾低档和副驾中档座椅通风;不改变座椅姿态。
icon: snowflake
operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风;该接口 2 为低档,不与副驾档位混用。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 23}, reason: 设置清凉但不过低的温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 3}, reason: 使用适中风量快速降温。}
- {domain: climate, capability: climate.set_cycle_mode, target_scope: vehicle, operation: set, parameters: {mode: internal}, reason: 使用内循环提高降温效率。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 2}, reason: 开启当前已接入的副驾座椅通风。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 0}, reason: 关闭遮阳帘减少日晒。}
+13
View File
@@ -0,0 +1,13 @@
id: winter-warmth
name: 冬季温暖模式
aliases: [暖冬模式, 冬日模式]
description: 使用内循环、暖风、关闭遮阳帘并分别开启主驾低档和副驾中档座椅加热;不改变座椅姿态。
icon: flame
operations:
- {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用已接入的主驾原生 CAN 低档加热;该接口 2 为低档,不与副驾档位混用。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 26}, reason: 设置温暖舒适温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 2}, reason: 使用中低风量。}
- {domain: climate, capability: climate.set_cycle_mode, target_scope: vehicle, operation: set, parameters: {mode: internal}, reason: 使用内循环提高升温效率。}
- {domain: seat, capability: seat.set_heating, target_scope: passenger, operation: set, parameters: {state: "on", level: 2}, reason: 开启当前已接入的副驾座椅加热。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 0}, reason: 关闭遮阳帘减少冷辐射。}
+34 -1
View File
@@ -3,6 +3,38 @@
"$id": "https://cognitive-agent.local/schemas/command-plan.schema.json",
"title": "CommandPlan",
"type": "object",
"$defs": {
"generatedProtocolContract": {
"type": "object",
"additionalProperties": false,
"required": ["source_version", "rule_id", "frame_key", "frame_id", "format", "brs", "dlc", "repeat", "feedback_ids", "feedback_policy"],
"properties": {
"source_version": {"type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$"},
"rule_id": {"type": "string", "pattern": "^can[01]\\.[a-z0-9_.]+$"},
"frame_key": {"type": "string", "minLength": 1},
"frame_id": {"type": "string", "pattern": "^0x[0-7][0-9A-Fa-f]{0,2}$"},
"format": {"type": "string", "enum": ["classic_can", "can_fd"]},
"brs": {"type": "boolean"},
"dlc": {"type": "integer", "minimum": 1, "maximum": 64},
"repeat": {
"type": "object",
"additionalProperties": true,
"required": ["kind", "count", "period_ms"],
"properties": {
"kind": {"type": "string", "enum": ["fixed", "special", "duration_ceiling", "sequence"]},
"count": {"type": "integer", "minimum": 0, "maximum": 10000},
"period_ms": {"type": "integer", "minimum": 0, "maximum": 60000}
}
},
"feedback_ids": {
"type": "array",
"uniqueItems": true,
"items": {"type": "string", "pattern": "^0x[0-7][0-9A-Fa-f]{0,2}$"}
},
"feedback_policy": {"type": "string"}
}
}
},
"additionalProperties": false,
"required": ["apiVersion", "kind", "metadata", "spec"],
"properties": {
@@ -41,6 +73,7 @@
"operation_ref": {"type": "string", "minLength": 2},
"bus": {"type": "string", "pattern": "^[a-z][a-z0-9_-]*$"},
"message": {"type": "string", "minLength": 1},
"protocol": {"$ref": "#/$defs/generatedProtocolContract"},
"signals": {
"type": "object",
"minProperties": 1,
@@ -85,6 +118,7 @@
"operation_ref": {"type": "string", "minLength": 2},
"bus": {"type": "string", "pattern": "^[a-z][a-z0-9_-]*$"},
"message": {"type": "string", "minLength": 1},
"protocol": {"$ref": "#/$defs/generatedProtocolContract"},
"signals": {
"type": "object",
"minProperties": 1,
@@ -120,4 +154,3 @@
}
}
}
@@ -16,6 +16,11 @@
"properties": {
"mode": {"type": "string", "enum": ["dry_run", "hil", "vehicle"]},
"status": {"type": "string", "enum": ["succeeded", "partially_succeeded", "failed", "cancelled"]},
"verification_status": {
"type": "string",
"enum": ["verified_success", "submitted_unverified", "failed_or_blocked"],
"description": "Highest task evidence level: feedback verified, transport submitted only, or submission blocked/failed."
},
"started_at": {"type": "string", "format": "date-time"},
"finished_at": {"type": "string", "format": "date-time"},
"physical_state": {
@@ -42,4 +47,3 @@
}
}
}
@@ -12,6 +12,17 @@
"capability": {"type": "string", "pattern": "^[a-z][a-z0-9_]*\\.[a-z][a-z0-9_]*$"},
"target": {"$ref": "common.schema.json#/$defs/target"},
"parameters": {"type": "object"},
"parse_source": {
"type": "string",
"enum": [
"local_rule",
"cloud_model",
"cloud_supplement",
"direct_control",
"saved_mode_command_flow",
"speech_semantic"
]
},
"motion": {
"type": "object",
"additionalProperties": false,
+32 -2
View File
@@ -1,12 +1,28 @@
FROM node:22-bookworm-slim AS codex-cli
ARG CODEX_CLI_VERSION=0.150.0-alpha.8
RUN npm install --global "@openai/codex@${CODEX_CLI_VERSION}" \
&& codex --version
FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONPATH=/app/src:/app/domains/seat/src
PYTHONPATH=/app/src:/app/domains/seat/src \
HOME=/home/cognitive-agent \
CODEX_HOME=/var/lib/codex
WORKDIR /app
RUN apt-get update \
&& apt-get install --yes --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=codex-cli /usr/local/bin/node /usr/local/bin/node
COPY --from=codex-cli /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s ../lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex
COPY pyproject.toml ./
COPY src ./src
COPY apps ./apps
@@ -15,7 +31,21 @@ COPY contracts ./contracts
COPY domains ./domains
COPY platforms ./platforms
RUN python -m pip install --no-cache-dir ".[llm]"
RUN node --version \
&& codex --version \
&& python -m pip install --no-cache-dir ".[llm,speech]" \
&& groupadd --gid 10001 cognitive-agent \
&& useradd --uid 10001 --gid 10001 --create-home cognitive-agent \
&& mkdir -p /var/lib/codex \
/app/artifacts/runs \
/app/artifacts/user-presets \
/app/artifacts/server \
/app/artifacts/tmp \
/app/artifacts/self-repair \
/app/artifacts/mode-profiles \
&& chown -R 10001:10001 /var/lib/codex /app/artifacts /home/cognitive-agent
USER 10001:10001
EXPOSE 8000
+74 -5
View File
@@ -13,22 +13,91 @@ The server layout is:
├── .env.auth # Web authentication settings, mode 0600
├── deploy/server/
├── artifacts/runs/ # Persistent request artifacts
└── artifacts/user-presets/
├── artifacts/user-presets/
├── artifacts/server/ # GSCU infrared catalog and server-side state
├── artifacts/tmp/ # Runtime gateway and calibration state
├── artifacts/self-repair/
└── artifacts/mode-profiles/ # User-created modes for all vehicles
```
Vehicle-specific user-created function modes are persisted from
`config/modes/vehicles/denza_n7/user/` into the otherwise read-only container.
User-created function modes are persisted from the ignored host path
`artifacts/mode-profiles/` into the service's separate writable user-mode
registry at `/app/artifacts/mode-profiles/`, including common and future
vehicle-specific modes.
Preset files under `/app/config/modes` remain read-only and are never modified.
Build and start the service:
```bash
cd /opt/511-project
chown -R root:root artifacts/runs artifacts/user-presets
chmod 0700 artifacts/runs artifacts/user-presets
mkdir -p \
artifacts/runs \
artifacts/user-presets \
artifacts/server \
artifacts/tmp \
artifacts/self-repair \
artifacts/mode-profiles
chown -R 10001:10001 \
artifacts/runs \
artifacts/user-presets \
artifacts/server \
artifacts/tmp \
artifacts/self-repair \
artifacts/mode-profiles
chmod 0700 \
artifacts/runs \
artifacts/user-presets \
artifacts/server \
artifacts/tmp \
artifacts/self-repair \
artifacts/mode-profiles
docker compose -f deploy/server/compose.yaml up -d --build
docker compose -f deploy/server/compose.yaml ps
```
镜像固定安装 `@openai/codex@0.150.0-alpha.8`。AI 助手使用命名卷
`codex_home` 保存 Codex 运行状态。N7Web 会在 App Server 启动握手期间验证模型认证,
认证缺失或初始化失败时不会把 `/api/health` 误报为 ready。
若 `.env` 同时设置 `OPENAI_BASE_URL` 和 `OPENAI_API_KEY`,且地址不是 OpenAI 官方
默认地址,N7Web 会为每个助手线程注入一个完整、固定的 OpenAI-compatible provider:
`base_url` 使用该地址,密钥只通过 provider 的 `env_key=OPENAI_API_KEY` 从子进程环境
读取,不会出现在命令行、状态或日志中。这一步是必须的;Codex 内置 `openai`
provider 不会把应用的 `OPENAI_BASE_URL` 自动当作模型端点。
若未设置自定义 `OPENAI_BASE_URL`,则使用 Codex 内置 OpenAI provider。已有
`codex_home/auth.json` 时复用现有登录;没有缓存登录但设置了 `OPENAI_API_KEY` 时,
N7Web 通过 App Server 的 API-key 登录接口完成初始化,Codex 会把认证状态写入
`codex_home`。因此该命名卷属于含凭据的持久数据,应限制管理员访问并纳入密钥轮换
流程。不要把 `auth.json` 复制进镜像、日志或仓库。仅设置 `CODEX_API_KEY` 不会让固定
版本的 App Server 自动登录。
若使用 ChatGPT 账号认证,推荐在无浏览器的服务器上执行 device-code 登录;普通网页
OAuth 依赖容器内 localhost 回调,容易在 headless 环境失败。登录完成后重启服务:
```bash
docker compose -f deploy/server/compose.yaml run --rm web codex login --device-auth
docker compose -f deploy/server/compose.yaml restart web
docker compose -f deploy/server/compose.yaml exec web codex login status
curl -fsS http://127.0.0.1:8511/api/health
```
App Server 线程采用 `read-only` sandbox 和线程级 `approvalPolicy=never`,并关闭
内置 shell、统一执行器、浏览器和应用工具;因此命令执行或文件修改不能通过交互式
审批获得额外权限。Codex 子进程只继承启动及模型认证所需的环境变量。容器使用无特权
UID `10001` 和只读根文件系统。
唯一启用的 `n7_vehicle` MCP 服务将工具级
`default_tools_approval_mode=approve` 固定为 `approve`。这与
`approvalPolicy=never` 不矛盾:前者仅避免 App Server 对已经进入 N7Web 安全边界的
MCP 调用再发起一次当前嵌入式网页无法响应的审批,后者仍让 App Server 自身的命令和
文件权限失败关闭。MCP 只开放运行时能力目录查询和回环地址上的座舱执行桥,其中只有
执行桥有副作用。工具令牌绑定原始用户请求,结构化候选计划还会经过原文证据、能力
注册表、参数 Schema、执行模式、车辆硬件审批与安全策略校验;模型不能提交替代指令
或原始 CAN,`approve` 也不会绕过这些应用层门禁。审批字段语义见
[Codex App Server 官方文档](https://learn.chatgpt.com/docs/app-server) 与
[Codex 配置参考](https://learn.chatgpt.com/zh-Hans/docs/config-file/config-reference)。
Add `deploy/server/Caddyfile.example` to the host Caddyfile, validate, and
reload Caddy:
+24 -2
View File
@@ -7,6 +7,7 @@ services:
dockerfile: deploy/server/Dockerfile
image: cognitive-agent-511:latest
container_name: cognitive-agent-511
user: "10001:10001"
restart: unless-stopped
init: true
env_file:
@@ -20,12 +21,30 @@ services:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
PYTHONPATH: /app/src:/app/domains/seat/src
CODEX_HOME: /var/lib/codex
CODEX_ASSISTANT_WORKSPACE: /tmp/codex-assistant-workspace
CODEX_ASSISTANT_MODEL: gpt-5.6-luna
CODEX_ASSISTANT_REASONING_EFFORT: low
CODEX_ASSISTANT_DISABLE_WEBSOCKETS: "true"
CODEX_ASSISTANT_STREAM_MAX_RETRIES: "1"
CODEX_ASSISTANT_TURN_TIMEOUT_SECONDS: "300"
CODEX_ASSISTANT_SCENE_TIMEOUT_SECONDS: "45"
CODEX_ASSISTANT_SCENE_CACHE_TTL_SECONDS: "3600"
CODEX_ASSISTANT_SCENE_CACHE_MAX_ENTRIES: "256"
CODEX_ASSISTANT_SCENE_MAX_CONCURRENT_PLANS: "8"
SPEECH_MODEL_FILE: model.int8.onnx
SPEECH_NUM_THREADS: "2"
ports:
- "127.0.0.1:8511:8000"
volumes:
- ../../artifacts/runs:/app/artifacts/runs
- ../../artifacts/user-presets:/app/artifacts/user-presets
- ../../config/modes/vehicles/denza_n7/user:/app/config/modes/vehicles/denza_n7/user
- ../../artifacts/server:/app/artifacts/server
- ../../artifacts/tmp:/app/artifacts/tmp
- ../../artifacts/self-repair:/app/artifacts/self-repair
- ../../artifacts/models:/app/artifacts/models:ro
- ../../artifacts/mode-profiles:/app/artifacts/mode-profiles
- codex_home:/var/lib/codex
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
@@ -39,7 +58,7 @@ services:
- CMD
- python
- -c
- import urllib.request; urllib.request.urlopen("http://127.0.0.1:8000/login", timeout=3)
- import urllib.request; urllib.request.urlopen("http://127.0.0.1:8000/api/health", timeout=3)
interval: 30s
timeout: 5s
retries: 3
@@ -49,3 +68,6 @@ services:
options:
max-size: 10m
max-file: "3"
volumes:
codex_home:
+81
View File
@@ -0,0 +1,81 @@
# CAN0 副驾座椅控制指令白名单
- 适用项目:`Cognitive_Agent`
- 适用分支:`feature/N7Web`
- 核对日期:2026-08-25
- 控制对象:CAN0 右前/副驾座椅
- 协议依据:`passenger_seat_firmware/firmware` 与 `Passenger_Seat_Controller_demo` 中共同存在的经典 CAN 协议
- 验证层级:固件源码、语义解析、参数门禁和离线编译;本次没有向真实 CAN 总线发送报文
CAN0 与 CAN1 必须分开解释。本文中的 `seat passenger ...` 只生成副驾座椅操作,不得改写为主驾 `seat driver ...`,也不得发往 CAN1。所有 CAN0 原始帧均为标准 11 位 ID、Classic CAN、DLC 8、无 BRS。
## 有效语义控制指令
| 类别 | 规范指令 | 参数约束 | 当前执行逻辑 |
| --- | --- | --- | --- |
| 水平绝对位置 | `seat passenger horizontal position <0-100>` | 整数百分比 | 有线和无线使用相同的固件原生 `0x300`:一般位置为 Opcode `0x01`;显式 0% 为安全归零 Opcode `0x0B`;均以 `0x301/0x302` 闭环确认 |
| 坐盆/高度绝对位置 | `seat passenger basin position <0-100>` | 整数百分比;对应固件 Motor 1 | 同上 |
| 靠背绝对位置 | `seat passenger backrest position <0-100>` | 整数百分比;对应固件 Motor 2 | 同上 |
| 腿托绝对位置 | `seat passenger legrest position <0-100>` | 整数百分比;对应固件 Motor 3 | 同上 |
| 水平相对移动 | `seat passenger horizontal relative <forward\|backward> <1-100>` | 移动量为整数百分比 | 先根据 CAN0 当前位置计算有界绝对目标;位置有效时使用闭环 Opcode `0x01`,位置未标定且移动量满足轴级限制时使用 Opcode `0x03` 有界定时回退;有线和无线完全一致 |
| 坐盆/高度相对移动 | `seat passenger basin relative <up\|down> <1-100>` | 同上;当前 C++ 台架工具依据实测把 `up` 映射为归一化位置增加、`down` 映射为减少 | 同上;该方向修正仅限 CAN0,不影响 CAN1 |
| 靠背相对移动 | `seat passenger backrest relative <forward\|backward> <1-100>` | 同上 | 同上 |
| 腿托相对移动 | `seat passenger legrest relative <up\|down> <1-100>` | 同上 | 同上 |
| 副驾通风 | `seat passenger vent <off\|low\|medium\|high>` | `low/medium/high` 对应 1/2/3 档 | 有线和无线均发送固件原生 `0x29E`,状态以 `0x4FA` 确认;无线模式把原始 CAN0 帧封装到 MQTT 控制话题 |
| 副驾加热 | `seat passenger heat <off\|low\|medium\|high>` | `low/medium/high` 对应 1/2/3 档 | 有线和无线均在开启时先发固件原生 `0x3CE` 模式,再发 `0x29E`,状态以 `0x4FA` 确认 |
| 木马模式 | `seat passenger dynamic rocking-horse` | 固件模式 ID 1 | 有线和无线均发送 `0x300` Opcode `0x0E`、ParamA=1,并以 `0x301/0x306` 确认 |
| 摇篮模式 | `seat passenger dynamic cradle` | 固件模式 ID 2 | 有线和无线均发送 `0x300` Opcode `0x0E`、ParamA=2,并以 `0x301/0x306` 确认 |
| 停止动态模式 | `seat passenger dynamic stop` | 无附加参数 | 有线和无线均发送 `0x300` Opcode `0x0F`、ParamA/ParamB=0 |
为了兼容现有页面,精确指令入口仍接受以下旧别名,但前端“已解析 CAN 控制指令”统一显示上表规范形式:
- `longitudinal` → `horizontal`
- `height` → `basin`
- `seat passenger dynamic start rocking-horse` → `seat passenger dynamic rocking-horse`
- `seat passenger dynamic start cradle` → `seat passenger dynamic cradle`
## 原生 0x300 控制字段
| Byte | 字段 | 说明 |
| ---: | --- | --- |
| 0 | Opcode | `0x01` 绝对、`0x02` 固件原生相对、`0x03` 有界定时、`0x08/0x09` 停止、`0x0B` 已知量程归零、`0x0E/0x0F` 启停动态模式 |
| 1 | Control | bit0..1 Motor,bit2..3 Direction,bit4..5 Protocol Version=1 |
| 2..3 | Sequence | uint16 little-endian |
| 4..5 | ParamA | 绝对目标 0..10000;原生相对量为 int16;定时时长 100..60000 ms;归零密钥 `0x4B52`;动态模式为 1/2 |
| 6..7 | ParamB | 绝对/相对超时 0 或 100..60000 ms;定时 Duty×10 为 300..1000;归零超时 10..600 s;动态模式必须为 0 |
当前自然语言相对移动采用“读取当前位置→计算目标→闭环执行”的安全策略,不直接无条件暴露 Opcode `0x02`。这是执行策略与语义命令的区别:用户仍能看到相对语义,底层可根据位置是否有效选择更安全的原始帧序列。
## CAN0 状态反馈与前端映射
| 反馈 ID | 固件含义 | 后端/前端映射 |
| --- | --- | --- |
| `0x301` | 系统状态、ACK、拒绝原因、有效轴掩码 | 作为命令 ACK 和安全诊断,不伪装成位置 |
| `0x302` | 单轴任务、方向、位置有效位、故障、归一化位置 | Motor 0→前后 `longitudinal`;1→高度 `cushion_tilt`;2→靠背 `backrest_recline`;3→腿托 `legrest_height`;`Value_X100 / 100` 显示为 0–100% |
| `0x303` | Hall、量程、速度、最近电机事件 | 诊断缓存,不覆盖 `0x302` 的权威位置 |
| `0x304` | 单轴电流与堵转诊断 | 诊断缓存 |
| `0x305` | 风扇/加热电流诊断 | 诊断缓存 |
| `0x306` | 木马/摇篮模式、阶段、周期、故障掩码 | `passenger.dynamic_mode`,前端显示运行/空闲/故障 |
| `0x4FA` | 副驾通风和加热状态 | `passenger.ventilation`、`passenger.heating` |
| `0x6A0/0x6A2` | 旧车机 seat-ota 查询/应答 | 保留兼容;位置查询应答转换为同一内部 `0x302` 状态,不与原生状态重复解释 |
`0x6A0/0x6A2` 只允许作为旧车机状态查询兼容输入。自然语言、设备控件和
无线 MQTT 控制白名单均禁止发送 `0x6A0`;无线只是把原生 CAN0 帧封装进
`n7/n7-headunit/can/command`,不会改变 CAN ID 或载荷。
无线状态来自 `n7/n7-headunit/can/state` 时,以上字段使用 `wireless_mqtt_ch0` 标记;有线台架使用 `physical_can`。前端只把这些物理来源显示为实车反馈。
## 自然语言示例
- “把副驾座椅前后位置调到 35%” → `seat passenger horizontal position 35`
- “副驾坐盆向上调 10%” → `seat passenger basin relative up 10`
- “副驾靠背向后移动 15%,空调设为 24 度” → CAN0 `seat passenger backrest relative backward 15` + CAN1 `ac temp driver 24`
- “开启木马模式并打开氛围灯” → CAN0 `seat passenger dynamic rocking-horse` + CAN1 `light ambient on`
- “关闭摇篮模式,风量调到 2 档,打开遮阳帘” → CAN0 `seat passenger dynamic stop` + CAN1 `ac fan 2` + `shade open`
## 安全边界
- 百分比越界、非法方向、未知轴和不在枚举内的动态模式会在生成 CAN 帧前拒绝。
- 相对移动不会使用虚构位置计算目标;位置无效时仅允许编译器已限制时长、方向和 Duty 的 `0x03` 回退,否则拒绝。
- MQTT Broker 接受消息只代表已提交;车辆动作是否完成仍以 CAN0 状态反馈为准。
- 固件还实现全量程校准、清故障和查询等维护 Opcode,但它们不属于面向自然语言和前端设备控件的控制白名单。
+88
View File
@@ -0,0 +1,88 @@
# CAN1 控制指令白名单
- 适用项目:`Cognitive_Agent`
- 适用分支:`feature/N7Web`
- 核对日期:2026-08-24
- 验证层级:自然语言解析、精确指令解析和 CAN1 白名单编译通过;未在本次核对中发送真实 CAN 帧。
“发送逻辑”列以当前执行器代码为准。有线模式按该顺序直接提交 CAN1 帧;无线 MQTT 模式将每个原始 CAN 帧分别封装为 1 条 `n7/n7-headunit/can/command` 消息(默认 QoS 1)。因此表中“3 帧,间隔 100 ms”在无线模式下表示后端连续发布 3 条 MQTT 消息,每条消息内部仍为 `repeat: 1`、`intervalMs: 0`。MQTT 客户端或 Broker 接受消息只代表已提交,车辆是否完成动作仍以状态话题的反馈帧为准。
## 有效白名单
| 类别 | 有效指令 | 参数约束 | 发送逻辑 |
| --- | --- | --- | --- |
| 后排门锁 | `door rear <lock\|unlock>` | 仅后排门锁 | `0x32B` 经典 CAN,发送 1 帧;反馈 ID `0x407` |
| 车窗动作 | `window <all\|front\|rear\|front-left\|front-right\|rear-left\|rear-right> <open\|close\|stop>` | 目标和动作必须使用列出的枚举值 | `0x431` CAN FD+BRS,连续 3 帧,相邻帧间隔 100 ms;反馈 ID `0x388` |
| 单窗位置 | `window <front-left\|front-right\|rear-left\|rear-right> position <0-100>` | 仅支持单个车窗,位置为整数百分比 | 先等待最长 2500 ms 的有效 `0x388`;已在目标 ±3% 内则发送 0 帧。否则用 `0x431` CAN FD+BRS 发送 3 帧启动命令(间隔 100 ms),最长闭环监听 `0x388` 12 s;无论到位或超时,最后强制发送 3 帧停止命令(间隔 100 ms) |
| 空调电源 | `ac <on\|off>` | 整车 HVAC 电源 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
| 空调风量 | `ac fan <1-7>` | 只接受 1–7 的整数 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
| 出风模式 | `ac mode <face\|face-feet\|feet\|feet-defrost\|defrost\|face-feet-defrost\|face-defrost>` | 仅接受列出的 7 种模式 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
| 空调温度 | `ac temp <driver\|passenger\|rear> <18-32>` | 温度只接受 18–32 的整数 | `0x1DE` 经典 CAN;`driver`/`passenger` 各发 3 帧(间隔 100 ms),`rear` 发 1 帧;反馈 ID `0x404` |
| 空调控制模式 | `ac control <manual\|auto>` | 手动或自动 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
| 空调循环 | `ac cycle <internal\|external>` | 内循环或外循环 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
| 单个风口开关 | `outlet <driver-left\|driver-right\|passenger-left\|passenger-right> <open\|close>` | 必须明确指定四个前排电动风口之一;不提供隐式默认目标 | `0x4EF` CAN FD+BRS,使用车机差分抓包确认的逐字节请求,连续 3 帧,间隔 100 ms;反馈 ID `0x4BB`/`0x37B` |
| 风口百分比 | `outlet <driver-left\|driver-right\|passenger-left\|passenger-right> percent <0-100> <0-100>` | 依次为左右、上下整数百分比 | `0x4EF` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x2F5`/`0x37B` |
| 风口标定坐标 | `outlet <区域> position <左右坐标> <上下坐标>` | 必须同时满足通用有符号范围和对应风口标定范围 | `0x4EF` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x2F5`/`0x37B` |
| 主驾座椅通风/加热 | `seat driver <vent\|heat> <off\|low\|high>` | `front-left` 可作为主驾输入兼容别名,规范输出仍为 `driver` | `0x431` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅按摩关闭 | `seat driver massage off` | 仅关闭动作 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅按摩强度 | `seat driver massage strength <weak\|medium\|strong>` | `1\|2\|3` 也可作为输入兼容别名,规范输出为单词枚举 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅按摩模式 | `seat driver massage mode <wave\|pulse\|stretch\|slack\|relax>` | 仅接受列出的 5 种模式;自然语言“打开主驾座椅按摩”使用已验证的 `relax` 模式启动 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅水平调节 | `seat driver horizontal <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
| 主驾座椅高度调节 | `seat driver height <up\|down> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
| 主驾座椅靠背调节 | `seat driver backrest <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
| 主驾座椅坐盆调节 | `seat driver basin <up\|down> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
| 主驾座椅停止 | `seat driver <horizontal\|height\|backrest\|basin> stop` | `stop` 后不允许附加持续时间 | `0x4C1` CAN FD+BRS,直接连续发送 3 帧 STOP,间隔 100 ms;反馈 ID `0x494` |
| 主驾座椅记忆 | `seat driver position <1-3>` | 需要先由操作员完成 PAD 只读学习 | `0x4C1` CAN FD+BRS;下发前最长等待 2500 ms 的新鲜 `0x494`,且四轴初始化均为真;然后将已学习的 `0x16 → 0x15` 交替序列发送 10 轮,共 20 帧,间隔 50 ms;发送后最长等待 1200 ms 的 `0x494` |
| 主驾座椅记忆兼容别名 | `seat driver memory recall <1-3>` | 与 `seat driver position <1-3>` 使用同一已学习帧序列 | 发送逻辑与 `seat driver position <1-3>` 完全相同:`0x4C1` CAN FD+BRS,四轴初始化门禁通过后发送 20 帧 `0x16/0x15` 交替序列,间隔 50 ms |
| 室内灯总开关 | `light indoor <on\|off>` | 整车室内灯总开关 | `0x4F5` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
| 全部阅读灯 | `light reading all <on\|off>` | CAN1 仅验证 `all` 目标 | `0x4F5` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
| 氛围灯开关 | `light ambient <on\|off>` | 氛围灯总开关 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
| 氛围灯颜色 | `light ambient color <1-127>` | 车型颜色索引,只接受整数 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
| 氛围灯亮度 | `light ambient brightness <1-5>` | 只接受 1–5 的整数档位 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
| 遮阳帘动作 | `shade <open\|close\|half\|stop>` | 0/50/100 位置与已验证预设动作复用 | `0x431` 经典 CAN;`open/close/half` 先发 1 帧预设命令,最长监听 `0x41A` 15 s,在匹配目标位置但仍在运动时追加 1 帧 STOP 并确认停止;`stop` 发 1 帧 STOP 并等待停止反馈 |
| 遮阳帘位置 | `shade position <0-100>` | 中间位置使用状态反馈闭环并自动停止 | 0/50/100 按上述预设逻辑发送。其他位置先等待最长 2500 ms 的新鲜 `0x41A`;已在目标 ±2% 内且停止则发 0 帧,已到位但仍运动则发 1 帧 STOP。否则用 `0x431` 经典 CAN 发 1 帧开/关启动命令,最长监听 15 s,并在 `finally` 中强制发 1 帧 STOP,再确认位置与停止状态 |
## 单个风口开关帧
以下请求来自车机逐个开关四个风口时的 CAN1 差分抓包。每条均为标准 ID `0x4EF`、CAN FD+BRS、DLC 8,连续发送 3 次,间隔约 100 ms:
| 目标 | `open` 数据 | `close` 数据 |
| --- | --- | --- |
| `driver-left` | `02 FF FF FF FF 01 FF FF` | `02 FF FF FF FF 02 FF FF` |
| `driver-right` | `02 FF FF FF FF 04 FF FF` | `02 FF FF FF FF 08 FF FF` |
| `passenger-left` | `03 FF FF FF FF 01 00 00` | `03 FF FF FF FF 02 00 00` |
| `passenger-right` | `03 FF FF FF FF 04 00 00` | `03 FF FF FF FF 08 00 00` |
副驾两个请求的末两字节必须保持为 `00 00`,不能复用主驾请求的 `FF FF`。`0x4BB` 位置签名及部分网关上的 `0x37B/0x50` 开关枚举用于协议反馈匹配;反馈匹配和 CAN ACK 都不能替代实体风口动作核对。
## 风口标定坐标范围
| 风口 | 左右范围 | 上下范围 |
| --- | ---: | ---: |
| `driver-left` | -25–40 | -40–30 |
| `driver-right` | -40–30 | -40–30 |
| `passenger-left` | -10–40 | -40–30 |
| `passenger-right` | -40–5 | -40–30 |
风口原始坐标语法层的通用范围为 -128–126,但只有同时落在上表对应风口实际标定范围内才能通过编译。
## 能力层扩展与非白名单入口
| 指令/能力 | 当前状态 | 发送逻辑 |
| --- | --- | --- |
| `ac compressor <on\|off>` | 能力目录保留 `climate.set_ac` 的虚拟编排;矩阵显示其应使用独立请求位和 `0x0DB` 反馈,但尚未完成实车 HIL 闭环验证,因此不属于原生 CAN 白名单 | 物理执行拒绝,发送 0 帧;不得复用空调电源位或把虚拟结果描述为车端完成 |
| `door vehicle <lock\|unlock>` | 可解析,但当前 CH1 实车验证未观察到有效整车门锁动作,因此会被安全拒绝,不属于可下发白名单 | 安全门禁拒绝,发送 0 帧 |
| `ac defrost <on\|off>` | 存在旧解析入口,但当前 CAN1 运行时未实现;前除霜应使用 `ac mode defrost` | 编译门禁拒绝,发送 0 帧 |
## CAN0 边界
本文档只描述 CAN1 车身控制白名单。CAN0 只用于副驾座椅,其状态、轴位置、热舒适和动态模式控制必须依据 `/home/orin/project511/passenger_seat_firmware` 中的固件原生协议独立处理,不得使用本 CAN1 白名单生成或猜测 CAN0 帧。
## 实现与回归测试
- 精确命令和自然语言匹配:`src/cognitive_agent/application/native_can_intent.py`
- 能力、参数及 CAN 帧编译门禁:`src/cognitive_agent/application/native_can_runtime.py`
- 风口标定范围:`platforms/t2g_cabin_bench/calibration/body_can.yaml`
- 完整白名单回归测试:`tests/unit/test_can1_semantic_whitelist.py`
白名单回归测试展开覆盖 129 条有效命令(包括内/外循环和 8 条单风口开关组合),并验证越界、非整数、非法枚举和超出风口标定范围的指令不能通过白名单门禁。`can_read_trans self-test` 还会将这 129 条指令按 Console 单批 32 条的上限拆成 5 批,逐条验证 CAN1 路由,并用一批 30 条指令覆盖所有 CAN1 控制类别的混合校验。
+46
View File
@@ -0,0 +1,46 @@
# 功能模式的座椅联动与时间轴
## 行为
新建或重新构建功能模式时,未指定座位默认只编排副驾座椅;明确“主驾”时只编排主驾座椅;只有明确要求主驾和副驾时才组合双座。该规则同时覆盖已登记模板、工程预设复制、未登记模型组合,以及时间轴每一个阶段。其他设备的独立区域不改变座椅选择,例如“主驾空调”并不代表选择主驾座椅。
模板保留主副驾能力作为可选来源。构建时服务端先按所选座位投影,再逐动作校验,不能把副驾靠背、腿托的位置能力重标为主驾。模型规划的能力目录也按所选座位收窄,越界方案整体拒绝。已经保存的用户模式及其后续参数编辑仍以原有配置为准,不因本次默认规则变更静默删除座椅动作。
常用阅读、观影、露营、夜间静享、接待、浪漫、冬季温暖、夏季清凉模板,以及办公、小憩、冥想、休闲躺椅预设,包含本工程支持的主驾和副驾舒适动作。主驾优先使用加热、通风、按摩,不凭空增加没有协议支持的位置动作。除明确座椅场景外,不随意改变主驾坐姿。
已有用户保存的配置优先,不被模板升级静默覆盖。要采用新版组合,可删除不再需要的旧自定义副本后重新构建,或明确修改已有模式。除霜、新风等专项模式不强行增加无关的座椅动作。
“构建一个座椅的动态模式”和“构建一个上车预热的功能模式”命中本地模板,无需模型推理。其他带时间变化的需求由结构化场景编排产生分阶段计划,每个阶段再次经过能力、参数、语义、安全和 CAN 编译校验;任意阶段失败则整份草稿拒绝,不生成静态替代品。
## 模板默认值
- 座椅动态舒缓:30 秒舒缓、30 秒支撑,重复 5 次,总计 5 分钟。默认副驾靠背与腿托小幅改变;明确主驾时改用主驾支持的按摩切换,不移动主驾姿态。仅应在车辆安全停稳后使用。
- 上车预热:升温阶段 2 分钟,空调 28℃、风量 4;舒适保持阶段 5 分钟,空调 24℃、风量 1,同时编排所选座位的加热(默认副驾)。总计 7 分钟。
预览卡片展示每阶段的目标与时长,默认值属于可审核的建议。生成和试运行都不下发实际车辆控制;保存后进入模式才开始计时。“上车预热”目前是模式名称,不表示已接入自动开门触发。
## 存储与执行
`cognitive-agent-mode-schedule/v1` 保存 `repeat_count` 与有序 `phases`。每阶段有唯一 ID、名称、`duration_ms` 和绝对目标 `operations`。同一能力在不同阶段的不同值不能合并或去重。
约束:2–8 阶段;每阶段 1–12 项动作,1 秒–15 分钟;1–20 轮,含重复的总时长不超过 1 小时。禁止无限循环、相对累加动作和未经校验的底层指令。
真实执行由服务端计时。每阶段重新编译并检查权限/硬件租约,任意阶段失败即停止后续阶段。同车只运行一条时间轴,新时间轴替换旧时间轴;同车阶段回调互斥。不同车辆独立。
输入“停止动态模式”可取消后续阶段。已下发的目标不自动回滚,当前已进入执行器的操作可能继续完成。模拟停止不会改变真实任务。服务关闭时取消时间轴,重启不会自动续跑;网页关闭不会隐式修改服务端已启动任务。
`GET /api/modes/schedule/status?vehicle_id=denza_n7` 只读返回运行 ID、会话、阶段和终态,不暴露底层指令或 CAN 数据。助手区分已启动、模拟完成、真实完成、失败和取消,不把刚启动显示为全部完成。
## 验证
核心时钟/并发/取消:`tests/unit/test_mode_schedule.py`。
真实能力链创建、试运行、保存、重载、执行与防篡改:`tests/integration/test_mode_schedule_flow.py`。
座椅模板覆盖:`tests/integration/test_registered_scene_seat_coverage.py`。
新建座位选择、模型目录约束、非座椅分区隔离、保存绑定及越界拒绝:`tests/integration/test_mode_creation_seat_policy.py`。词汇与路由:`tests/unit/test_mode_creation_seat_policy.py`、`tests/unit/test_mode_creation_seat_routing.py`。
时间轴卡片与状态轮询:`tests/unit/mode_schedule_frontend.test.cjs`;隔离服务的真实浏览器:`tests/e2e/test_mode_schedule_browser.py`。
自动化测试使用模拟执行、临时用户目录并阻断硬件访问,不代表完成实车 CAN 验收。
@@ -0,0 +1,424 @@
# N7 红外学习与 NFC 外设场景设计
## 1. 设计边界
本方案严格基于当前自扩展链路:
```text
外设原遥控器
→ 红外学习器学习动作信号
→ 动作与设备/槽位信息写入 NFC 能力卡
→ 车端 NFC 读卡器识别设备卡或场景卡
→ 场景结合车端实时状态和 CAN 反馈进行编排
→ 红外控制器发射已学习信号
→ 外设执行动作
```
由此产生三个设计原则:
1. 外设必须真正使用红外遥控,不能把蓝牙、2.4 GHz RF、Wi-Fi 或自制传感器设备混入当前链路;
2. 车端有权威状态反馈,可以用于条件、分支和阶段完成;红外外设通常没有状态回传,因此只能标记“红外指令已发出”,不能声称物理动作已确认;
3. 深度联动应采用“NFC 选择/触发 → 车端设备执行并等待反馈 → 满足条件后发射外设红外动作”,而不是让多个设备无条件同时开关。
## 2. 红外外设准入条件
适合进入预设和自定义场景的外设应满足:
- 遥控器确认为 36~40 kHz 常见红外载波,而不是 RF、蓝牙或 2.4 GHz;
- 每个关键能力具有固定、可重复学习的红外码,无滚动码或动态配对;
- 优先提供独立 `ON`、`OFF`、固定颜色、固定档位和固定模式指令;
- 只有单一 `POWER TOGGLE` 或循环切换模式的设备,不进入自动预设,只允许人工单次控制;
- 动作在 1~3 次重复发射下稳定,长按型动作必须验证重复周期和停止指令;
- 外设使用 5V USB 或受控 12V 低压供电,功率、温升和固定方式适合车内;
- 红外接收窗可被控制器稳定照射,设备安装后不易遮挡或移动;
- 外设失控不会夹伤、烫伤、影响驾驶或阻断车端安全能力;
- 动态灯光、投影和移动玩具只能在 P 挡、零车速场景运行。
### 2.1 学习验收
每个候选外设在进入能力目录前至少验证:
| 项目 | 要求 |
|---|---|
| 协议类型 | 红外学习器能稳定识别,不是 RF/BLE |
| 单动作复现 | 连续执行 30 次,成功率达到展项要求 |
| 幂等性 | 重复发送固定 ON/OFF/档位不会产生相反结果 |
| 响应时间 | 记录发射次数、间隔和设备响应延迟 |
| 断电恢复 | 明确断电后默认状态,不能依赖未知记忆状态 |
| 码冲突 | 同一车内其他外设不会被该动作误触发 |
| 安装 | 接收窗与发射器保持视距,颠簸后仍然可靠 |
## 3. 符合车端需求的外设清单
## 3.1 A 级:建议作为首批展示和产品外设
| 外设 | 车端真实需求 | 必须具备的红外遥控能力 | 可联动车端能力 | 适用场景 |
|---|---|---|---|---|
| 红外 RGB/流水灯带 | 原车氛围灯亮度和动态效果不足;缺少空间状态提示 | 独立开/关、固定色、亮度、呼吸、渐变、流水、速度 | 原车氛围灯、阅读灯、车窗、遮阳帘、空调、无线充电 | 光域增强、离车检查、充电提示、影院、故事 |
| 红外星空投影灯 | 驻车休息和儿童场景缺少顶部沉浸效果 | 独立开/关、固定颜色、旋转开/关、亮度、定时 | 遮阳帘、阅读灯、座椅、空调、氛围灯 | 星际故事、晚安、充电休憩 |
| 红外桌面/夹持风扇 | 后排或局部送风不足;暴晒后需要快速换气 | 独立开/关、1~3 档、摇头开/关、定时 | 车窗、空调、内外循环、座椅通风、舱内温度 | 极速凉舱、露营通风、局部舒适 |
| 红外故事机/白噪音机 | 充电等待、儿童陪伴、午休需要非屏幕声音 | 开/关、播放、暂停、音量档位、曲目/模式、睡眠模式 | 座椅、遮阳帘、阅读灯、空调 | 故事舱、晚安、冥想、阅读 |
| 红外机器人/玩偶 | 需要有角色感和肉眼可见的互动外设 | 唤醒、挥手、说话、唱歌、跳舞、停止、休眠 | 灯光、座椅姿态、座椅动态模式、空调风量 | 星际故事、生日、迎宾、充电游戏 |
| 红外补光灯 | 车内直播、视频会议、补妆和拍摄时原车灯位不合适 | 独立开/关、亮度档位、冷/暖/自然光、固定效果 | 遮阳帘、座椅、阅读灯、空调 | 移动摄影棚、视频会议、补妆 |
## 3.2 B 级:可以扩展,但需挑选具体型号
| 外设 | 价值 | 红外要求与问题 | 建议 |
|---|---|---|---|
| 红外微型投影仪 | 驻车影院、露营娱乐 | 很多型号只有电源 Toggle,启动慢,关机可能需二次确认 | 选择具备独立关机指令、自动信号源和稳定启动时间的型号 |
| 红外空气净化器 | 停车休息、宠物和空气舒适 | 需排除臭氧/强离子模式;体积和供电需适车 | 仅作为空调辅助,不能冒充车端空气质量保障 |
| 红外露营灯/尾门灯 | 尾门和帐篷区照明 | 动态模式可能通过循环键选择,状态不确定 | 只学习固定暖光、白光、亮度和明确关闭 |
| 红外 LED 字幕牌/像素屏 | 生日、应援、露营提示 | 遥控器通常只能切换预置内容,不能动态写文字 | 提前写入固定图案,把红外动作定义为“播放预设 1/2/3” |
| 红外香氛机 | 午休、迎宾和放松 | 液体倾倒、过敏、宠物和儿童限制 | 只选防倾倒、低压、雾化与灯光分离控制型号,默认不参与场景 |
## 3.3 C 级:不建议进入首批车端场景
- 只有 RF/蓝牙遥控的遥控车、无人机和大量消费级玩具;
- 依赖长按且没有独立 STOP 的移动玩具;
- 电热毯、暖风机、加热坐垫等高热设备;
- 无防护激光投影、UV 消毒灯和高亮快闪设备;
- 易倾倒加湿器、开放液体装置和烟雾机;
- 高扭矩机械支架、伸缩机构和裸露电机;
- 仅有电源 Toggle 且无法确定当前状态的设备。
## 4. NFC 卡设计
建议把 NFC 分成两类,既符合当前学习链路,也方便构建预设和自定义场景。
### 4.1 外设能力卡
每个红外外设对应一张能力卡,记录:
```yaml
kind: PeripheralCapabilityCard
device_id: ir-strip-01
device_type: ambient_light_strip
capabilities:
- name: light.power_on
ir_slot: 21
- name: light.power_off
ir_slot: 22
- name: light.blue
ir_slot: 23
- name: light.breathe
ir_slot: 24
compatibility:
power: usb_5v
parked_only_effects: [flash, chase]
```
刷外设卡用于识别设备、导入能力、单项测试或替换场景中的同类设备。
### 4.2 场景卡
场景卡不重复保存所有原始红外波形,而是引用已经学习的外设能力和车端场景版本:
```yaml
kind: SceneCard
scene_id: family-space-adventure
scene_version: 3
bindings:
ambient_booster: ir-strip-01
projector: star-projector-01
story_actor: robot-01
```
用户刷“月球、火星、返航、极速凉舱、影院”等实体场景卡,即可触发预设或自定义功能。
## 5. 场景一:光语座舱
### 5.1 产品需求
原车氛围灯不仅偏弱,车辆状态也集中显示在屏幕。外接红外灯带可以同时承担氛围增强和离散状态提示。虽然红外无法做连续参数控制,但固定颜色、呼吸、流水和快慢模式足够形成状态语言。
### 5.2 车端状态驱动灯带
| 车端状态 | 红外灯带动作 | 原车设备配合 |
|---|---|---|
| 正常氛围模式 | 固定主题色或低速渐变 | 原车氛围灯同色低亮 |
| 座椅正在调整 | 蓝色流水 | 座椅到位后切换固定蓝色 |
| 动态摇篮运行 | 蓝色呼吸或慢速渐变 | 停止后恢复当前主题 |
| 空调高风量制冷 | 蓝色快速渐变 | 风量降低后切换慢速渐变 |
| 任一车窗未关好 | 红色慢闪 | 全部车窗反馈关闭后绿灯一次 |
| 遮阳帘移动 | 暖白呼吸 | 到位后恢复主题色 |
| 手机正在无线充电 | 蓝色呼吸 | 满电切换绿色,故障切换红色闪烁 |
| 场景正常结束 | 绿色呼吸一次后恢复/关闭 | 依据场景恢复原车灯光 |
### 5.3 状态优先级
灯带同一时间只能处于一个红外模式,必须规定优先级:
```text
车窗/无线充电故障提示
> 场景中止提示
> 座椅/遮阳帘运动反馈
> 充电与空调状态
> 普通氛围效果
```
仅在状态变化时发射红外信号,并设置最小保持时间,避免车端状态抖动导致频繁切换。
### 5.4 自定义示例
> 我的副驾座椅调整时灯带用蓝色流水,到位后变成湖蓝;如果有车窗没关好,始终优先红色慢闪。
## 6. 场景二:烈日快速凉舱
### 6.1 产品需求
车辆暴晒后,仅靠空调内循环降温较慢;外接红外风扇可以辅助排出热空气并改善副驾或后排局部送风。车端舱温、车窗和空调反馈决定外设风扇的运行阶段。
### 6.2 状态驱动流程
1. 用户刷“极速凉舱”场景卡或选择预设;
2. 系统确认 P 挡、零车速和舱内温度高于设定阈值;
3. 车窗打开到预设通风开度;只有实际车窗反馈匹配后,才向红外风扇发送“高档 + 摇头”;
4. 灯带显示橙色快速渐变,表示热空气排出阶段;
5. 达到通风时长或舱温开始下降后,车窗关闭;
6. 必须等待四窗全部关闭反馈,才切换空调低温、内循环、高风量、副驾座椅通风;
7. 红外风扇切换中档,灯带切换蓝色渐变;
8. 舱温达到目标范围后,外接风扇降到低档或关闭,车端空调恢复舒适档,灯带变为稳定湖蓝;
9. 任何车窗关闭失败都不会进入内循环完成状态,界面和灯带保持故障提示。
外接风扇没有物理反馈,因此执行报告写“风扇高档红外指令已发出”,舱温变化只能作为整体效果证据,不能证明风扇自身状态。
## 7. 场景三:驻车沉浸影院
### 7.1 外设
- 红外微型投影仪或星空投影灯;
- 红外灯带;
- 可选红外音响/白噪音设备;
- 可选红外风扇。
### 7.2 联动流程
1. 刷“影院外设套装卡”或“驻车影院”场景卡;
2. 校验 P 挡和零车速;
3. 关闭四窗、关闭遮阳帘、关闭阅读灯,副驾进入影院姿态,空调进入低噪声档;
4. 场景等待四窗、遮阳帘和座椅的真实反馈;
5. 只有遮光和座椅条件满足后,才发送投影仪/星空灯 ON、灯带深蓝和外部音响播放;
6. 座椅被人工调整时,影院继续,但场景释放座椅,不在结束时覆盖用户的新姿态;
7. 挡位离开 P 或用户取消时,优先发送外设明确 OFF,再停止动态灯效并恢复仍由场景持有的车端资源;
8. 如果投影设备只有 Power Toggle,不允许加入该预设。
这比“同时打开投影和调座椅”多了一层必要关系:车辆先完成遮光、姿态和安全条件,外设才进入工作状态。
## 8. 场景四:NFC 星际故事卡
### 8.1 产品需求
充电和停车等待时,通过实体 NFC 卡选择故事章节,减少儿童持续观看屏幕。红外玩偶、投影灯和灯带提供外部角色与舞台,车端座椅、空调和遮阳帘成为剧情的一部分。
### 8.2 外设与场景卡
- 红外星空投影灯;
- 红外故事机器人/玩偶;
- 红外灯带;
- 可选红外风扇;
- “启程、月球、火星、返航”四张 NFC 场景卡。
### 8.3 章节联动
#### 启程卡
1. 检查 P 挡和零车速;
2. 遮阳帘关闭、阅读灯关闭、副驾进入休闲姿态;
3. 遮阳帘反馈到位后,星空投影灯开启;
4. 座椅反馈到位后,玩偶执行“挥手 + 发射台词”;
5. 灯带进入蓝色流水,空调风量短时提高形成“启动气流”。
#### 月球卡
- 原车氛围灯切换冷蓝;
- 灯带切换白蓝呼吸;
- 空调转为低风量;
- 原车灯光反馈匹配后,玩偶讲述月球章节;
- 投影灯切换白色星空和慢速旋转。
#### 火星卡
- 原车氛围灯和灯带切换红色;
- 副驾短时进入轻柔动态摇篮;
- 动态模式反馈开始后,风扇高档模拟沙尘气流,玩偶执行火星表演;
- 动态模式结束后,风扇降档,灯带从红闪切换稳定红色。
#### 返航卡
- 红外玩偶 STOP、投影 OFF、风扇 OFF、灯带暖白;
- 车端座椅、空调、遮阳帘和原车灯光按资源所有权恢复;
- 所有可确认车端状态恢复后,灯带发送绿色呼吸一次再关闭。
### 8.4 自定义方式
用户可以学习自己玩偶的动作,并修改实体章节卡:
> 火星卡里不要让原来的机器人表演,改成我的恐龙叫三次;返航时先关投影,再恢复座椅。
这充分利用“红外信号写入 NFC、刷卡选择能力”的现有链路,同时形成实体、无屏和可自定义的车端产品体验。
## 9. 场景五:离车确认光带
### 9.1 产品需求
离车前用户需要确认车窗、遮阳帘和无线充电位,但这些状态分散在屏幕中。灯带可以作为最终确认出口。
### 9.2 联动流程
1. 刷“离车”场景卡或在车机选择离车预设;
2. 任一车窗未完全关闭:灯带红色慢闪,车机标出具体车窗;
3. 遮阳帘未达到离车目标:灯带暖白呼吸,车机继续关闭并等待反馈;
4. 左右无线充电区域仍有设备或出现故障:灯带黄色闪烁,提醒取走手机;
5. 所有可确认项目满足后:灯带绿色呼吸一次,原车氛围灯与灯带关闭;
6. 外设灯带只负责提示,不替代车端状态判断。
这是灯带比“第三方氛围灯”更进一步的长期产品功能。
## 10. 场景六:移动摄影棚
### 10.1 外设
- 红外补光灯;
- 红外背景灯带;
- 可选红外微型风扇。
### 10.2 NFC 风格卡
- 暖肤人像;
- 冷白会议;
- 蓝紫短视频;
- 证件照。
### 10.3 联动流程
1. 刷风格卡,确认 P 挡和零车速;
2. 遮阳帘调整到该风格的目标位置,座椅进入镜头姿态;
3. 阅读灯关闭,空调降低风量和噪声;
4. 等待遮阳帘、座椅和空调状态反馈;
5. 向补光灯发送固定色温与亮度,背景灯带发送主题色;
6. 用户调整座椅后,场景释放座椅控制,不自动拉回;
7. 用户刷“摄影结束”卡,补光灯和背景灯明确 OFF,车端状态恢复。
## 11. 场景七:露营布营与收营
### 11.1 外设
- 红外露营灯/尾门灯;
- 红外风扇;
- 红外投影仪;
- 红外灯带。
### 11.2 两张关键场景卡
**布营卡**:车端先完成驻车、车窗通风开度、座椅和灯光设置;收到车端反馈后,再开启露营灯、风扇和投影。
**收营卡**:先向所有红外外设发送明确 OFF,再关闭车窗和遮阳帘、恢复座椅和空调;车端反馈全部完成后灯带绿色提示。
收营场景的实际价值高于单纯派对效果,因为它把容易遗漏的多设备关闭动作集中处理。红外外设没有状态反馈,因此界面显示“已发送关闭指令”,仍建议用户目视确认。
## 12. 推荐技术展配置
### 12.1 四件主外设
1. 红外 RGB/流水灯带:兼顾氛围增强和状态提示;
2. 红外桌面风扇:体现舱温、车窗、空调和外设的状态驱动协同;
3. 红外星空投影灯:提供强视觉效果;
4. 红外机器人/故事玩偶:提供角色和动作反馈。
可选第五件是红外补光灯,用于展示成人创作者场景。
### 12.2 六张展示卡
- 灯带能力卡;
- 风扇能力卡;
- 启程场景卡;
- 月球场景卡;
- 火星场景卡;
- 返航场景卡。
### 12.3 展示顺序
1. 分别学习灯带和风扇动作并写入能力卡,证明外设适配;
2. 刷“极速凉舱”,展示车窗反馈驱动风扇档位与灯带状态;
3. 刷“启程/月球/火星”实体卡,展示车端状态决定投影、玩偶和风扇的动作时机;
4. 刷“返航”,展示外设明确关闭、车端恢复与结果分级;
5. 修改火星卡中的玩偶绑定或动作顺序,保存为自定义场景卡。
## 13. 对现有工程的增量
### 13.1 外设能力模型增加红外特性
```yaml
transport: infrared
ir_slot: 21
idempotent: true
command_semantics: power_off
repeat: 2
interval_ms: 250
parked_only: false
physical_feedback: unavailable
```
只有 `idempotent=true` 的关键开关和固定档位能力才能进入自动预设。
### 13.2 场景增加车端反馈门控
```yaml
- id: close_shade
capability: vehicle.sunshade.set
parameters: {covered_percent: 100}
wait_for:
state: vehicle.sunshade.covered_percent
equals: 100
timeout_ms: 8000
- id: start_projector
depends_on: [close_shade]
capability: peripheral.projector.power_on
confirmation: ir_sent_only
failure_policy: continue
```
### 13.3 红外外设影子状态
车机为每个外设保存“最后一次已发送的确定指令”,仅用于避免重复操作和生成恢复计划。影子状态不能作为物理状态证据。设备重新上电、NFC 重新识别或红外链路异常后,影子状态必须失效。
### 13.4 多设备红外管理
- 记录每个外设协议、地址、重复次数和发射间隔;
- 做码冲突测试;
- 同一时刻的多个红外动作在控制器侧顺序发射;
- 控制器和设备接收窗必须保持固定视距;
- 对移动玩具保留独立 STOP,并在取消时优先发送。
### 13.5 结果分级
- `车端反馈已确认`:CAN0/CAN1 状态匹配;
- `红外网关已确认`:控制器确认接收了发射请求;
- `红外指令已发出`:无法证明外设物理状态;
- `场景已降级`:缺少外设能力,跳过可选外设节点;
- `场景已拒绝`:缺少必要的独立 OFF/STOP 或安全条件不满足。
## 14. 实施顺序
### P0:使用现有红外/NFC能力
- 按准入条件采购并筛选灯带、风扇、投影灯和玩偶;
- 学习并验证固定 ON/OFF、颜色、档位、模式和 STOP;
- 制作能力卡与“启程、月球、火星、返航”场景卡;
- 实现极速凉舱、驻车影院和星际故事三个预设;
- 自定义场景先支持替换外设、调整顺序和修改车端参数。
### P1:补齐状态驱动
- Android 场景执行器增加 `wait_for`、车端条件分支和超时降级;
- 光语座舱监听车窗、座椅、空调、遮阳帘和无线充电状态变化;
- 红外外设增加幂等性、重复参数、影子状态和恢复规则;
- 浏览器只负责编辑和观察,场景下发到车机本地执行。
### P2:形成产品目录
- 发布红外外设兼容性清单和认证测试;
- 提供官方能力卡、场景卡和可复制模板;
- 允许用户将自己学习的红外动作绑定到官方场景角色;
- 对香氛、净化、移动玩具和投影设备建立专项安全限制。
+67
View File
@@ -0,0 +1,67 @@
# Speech pipeline benchmark — 2026-08-28
## Environment
- Windows / Python 3.13
- `sherpa-onnx==1.13.6`, CPU provider, 8 ASR threads
- SenseVoiceSmall full precision, `language=zh`, `use_itn=true`
- Silero VAD: threshold 0.45, min speech 0.16 s, min silence 0.65 s
- 48 text regressions(including reported field failures plus ratio/coordination matrices)
- 16 capability-alignment cases replayed 100 times(1600 samples)
- 43 catalog capability definitions × 5 resolver projections(215 subcases)
- 10 local 16 kHz mono Windows SAPI WAVs covering filler, target refinement,
cross-domain correction, normal multi-command, number/direction correction,
negation, unrelated speech, fast speech and slow speech
The WAV set is synthetic and deliberately kept under ignored `artifacts/tmp`; it is useful for
repeatability but is not a substitute for TAKSTAR V2 recordings in the real cabin.
## Before / after
| Metric | Before | After |
|---|---:|---:|
| Raw exact transcription (10 WAV) | 8/10 | 8/10 |
| Expected understanding text (same 10 cases) | 2/10 | 10/10 |
| Intent exact match | no structured speech Intent | 10/10 |
| Device / action / parameter exact | no structured speech metrics | 10/10 / 10/10 / 10/10 |
| Correction success | unsafe text-only heuristics | 4/4 |
| False command activation | 1 observed wrong-direction activation | 0/2 no-command cases |
| Severe wrong device / wrong number | not reported | 0 / 0 |
| Duplicate transcript rate | not observable (single callback) | 0% |
| ASR inference P50 / P95 | 80 / 110 ms | 79.7 / 110.5 ms |
| Semantic P50 / P95 | 0.03 ms (old regex only) | 0.33 / 0.56 ms(1600 capability samples) |
| Command resolution P50 / P95 | not available | 0.03 / 0.06 ms |
| Steady end-to-end P50 / P95 | not instrumented | 95.3 / 134.1 ms |
| Cold first request | not instrumented end-to-end | 1400.9 ms |
Raw exact stays at 80% because two files differ only in acoustic homophone/punctuation output;
`raw_text` is intentionally not rewritten. The semantic and business metrics operate on separate
fields.
## Full precision vs int8 probe
On sherpa-onnx's official Chinese test WAV, both files produced the same transcript in this one
probe:
| Model | First call including load | Warm P50 | Warm P95 |
|---|---:|---:|---:|
| `model.onnx` | 1514.9 ms | 90.9 ms | 92.7 ms |
| `model.int8.onnx` | 889.6 ms | 77.4 ms | 81.8 ms |
This is insufficient acoustic coverage to prove equal accuracy, so the accuracy-first default
remains full precision. The model file stays configurable through `SPEECH_MODEL_FILE`.
## VAD tuning probe
Thresholds 0.35 / 0.45 / 0.55 and end silence 0.45 / 0.65 / 0.85 s were replayed against the
available Chinese test WAV. All combinations found one segment; threshold 0.45 preserved the
complete utterance boundary. The selected 0.65 s server endpoint, 0.25 s pre-roll and 0.35 s
post-roll are conservative for short commands. Real cabin noise and long-pause recordings remain
required before claiming production calibration.
Reproduce current metrics with:
```bash
.venv/Scripts/python tools/benchmark_speech_pipeline.py \
--audio-dir artifacts/tmp/speech-regression-audio
```
+116
View File
@@ -0,0 +1,116 @@
# 中文语音控制链路
## 整改前的实际链路
```text
getUserMedia
-> ScriptProcessor(4096)
-> 浏览器 RMS 阈值与前后裁剪
-> 单个 16 kHz PCM WAV
-> POST /api/speech/transcribe
-> 单个 sherpa-onnx OfflineStream
-> SenseVoiceSmall 单次 final decode
-> regex 文本清理
-> 直接覆盖输入框
-> 用户提交后进入既有 IntentIR / capability / CAN 链路
```
旧实现没有 AudioWorklet、ASR Web Worker、WASM、streaming partial、VAD segment、
chunk overlap 或 segment assembler。因此观测到的“空调打开打……靠背靠座椅靠背”
不是 partial/final 被前端重复 append,而是 SenseVoice 单次 decode 已返回该 raw 文本;
旧正则层只是尝试掩盖 acoustic false start,且没有结构化 cancelled intent。
## 整改后的链路
```text
Microphone (优先 TAKSTAR V2)
-> AudioContext(跨 utterance 复用)
-> AudioWorklet(旧环境 ScriptProcessor fallback)
-> 自适应 RMS endpoint + 350 ms 前置 / 450 ms 后置采集
-> 16 kHz PCM WAV + utteranceId
-> Silero VAD(0.25 s pre-roll / 0.35 s post-roll,重叠先合并)
-> SenseVoiceSmall zh + ITN(每个 VAD segment 一个 OfflineStream)
-> final ASR callback
-> TranscriptAssembler(replace / commit / order / idempotency / overlap)
-> rawText
-> UnderstandingTranscriptProcessor
-> cleanText + active commands + cancelledFragments + uncertain
-> 页面展示与提交安全门
-> 用户确认后进入既有 IntentIR / capability / CAN 链路
```
模型、tokens、VAD native session 和浏览器 AudioContext 都只初始化一次。服务端请求以
`utteranceId + SHA-256(audio)` 幂等:相同音频返回缓存,不同音频复用同一 ID 返回 409。
## 数据边界
- `raw_text`:SenseVoice 原始证据,只去模型标签和多余空白,永不被语义文本覆盖。
- `clean_text`:用于输入框显示的理解式文本。
- `commands`:与工程 capability catalog 对齐的 active Intent 预览。
- `cancelled_fragments`:否定、自我修正、false start 或显式撤销的证据及 cancelled Intent。
- `uncertain`:参数越界、缺少方向/单位/设备、拼接冲突等不能安全执行的情况。
- `execution_eligible`:存在 active commands、无 uncertain、无校验错误时才为 true。
语音 API 本身不执行任何能力。页面会拒绝直接提交仍带有原始 uncertain 标记的语音结果;
用户编辑后才解除该标记。
## Capability 驱动的语义链路
语音层不再维护可执行设备表。能力事实按以下优先级读取:
1. `domains/*/config/capabilities.yaml` 与
`config/native_can_capabilities/*.yaml`;
2. `CapabilityRegistry` 统一加载 catalog;
3. `NativeCanRuntime.supported_capabilities`、domain runtime、seat compiler 和
mode profile registry 确认可执行性与 executor binding;
4. `CapabilityResolver` 只把中文槽位或既有一句话解析 action 适配到上述事实。
`UnderstandingTranscriptProcessor` 会优先复用
`application/native_can_intent.py` 已有的确定性解析结果,所以驾驶台默认目标、CH1
主驾能力和网页一句话编排保持一致。`vocabulary.py` 只包含 alias、同义词、颜色与
scope 词法映射,不声明车辆支持哪些能力。
每个 command 同时保留 `target_scope`、`property`、`operation`、`value`、`unit`、
`qualifiers`、IntentIR action、executor binding 和 executor payload。`clean_text` 从
这些结构化 command 渲染;unsupported、ambiguous、incomplete 不会产生假的
executor payload,且 UI 会阻止整条原始语音直接提交。
当前代码证据中,前大灯只有反馈和只读 UI,没有控制 capability;因此“打开前大灯”
为 `recognized_unsupported`。零重力是 `config/modes/vehicles/denza_n7/zero_gravity.yaml`
定义的副驾四轴 mode profile,不是主驾单一 capability;明确要求主驾时返回
`recognized_unsupported`。
能力矩阵始终从 catalog 动态生成:
```bash
python tools/report_capability_matrix.py --format markdown
```
## Debug trace
生产默认关闭。设置 `SPEECH_DEBUG=1` 后,每条 JSON 日志包含:
- `utteranceId`;
- `segmentId`、sequence、开始/结束时间、音频 overlap;
- raw ASR final、进入拼接器前文本、拼接后文本;
- clean text、active/cancelled Intent、uncertain reason;
- audio、VAD、每段 ASR、assembly、semantic、recovery、total 耗时。
`SPEECH_DEBUG_RESPONSE=1` 才把 trace 放进 API 响应。浏览器使用 `?speechDebug=1`
查看采集实现、设备、阈值、utteranceId 和服务器结果。
## 默认 VAD 与识别配置
| 配置 | 默认值 |
|---|---:|
| language | `zh` |
| use_itn | `true` |
| Silero threshold | `0.45` |
| min speech | `0.16 s` |
| min silence | `0.65 s` |
| max speech | `12.0 s` |
| pre-roll | `0.25 s` |
| post-roll | `0.35 s` |
参数可通过 `SPEECH_VAD_*` 调整。真实 TAKSTAR V2 车内噪声录音仍需要继续扩展 audio
regression suite;没有真实证据时不自动切换到 int8,也不自动下载 Recovery 模型。
@@ -0,0 +1,111 @@
# 自然语言转 CAN 控制指令测试报告
## 1. 测试结论
| 项目 | 结果 |
| --- | ---: |
| 测试日期 | 2026-08-25 |
| pytest 测试入口 | 2 |
| 生成式组合用例 | 1133 |
| 定向 CAN0/CAN1 组合用例 | 6 |
| 实际执行子用例 | **1139** |
| 通过 | **1139** |
| 失败 | **0** |
| 跳过 | **0** |
| 通过率 | **100%** |
| 执行耗时 | 35.42 s |
**结论:**当前本地规则能够将本次覆盖的自然语言控制指令正确解析为 CAN0/CAN1 语义控制指令;跨通道组合中,副驾座椅指令保持在 CAN0,主驾座椅、空调、灯光、车窗、门锁和遮阳帘等指令保持在 CAN1。白名单外指令未生成 CAN 控制流。
## 2. 测试方式
- 解析器:本地规则(`provider=local`)
- 执行模式:`dry_run`
- 硬件执行:关闭
- 真实 CAN 报文下发:未进行
- 测试入口:`tests/integration/test_natural_can_command_combinations.py`
- 机器可执行用例:`tests/fixtures/natural_can_command_combination_suite.json`
- 完整逐条用例:`docs/testing/自然语言转CAN组合测试用例.csv`
执行命令:
```bash
PYTHONPATH=src:domains/seat/src .venv/bin/python -m pytest -q tests/integration/test_natural_can_command_combinations.py
```
原始结果:
```text
2 passed, 2 warnings, 1139 subtests passed in 35.42s
```
2 个警告均为 `jsonschema.RefResolver` 弃用提示,不影响本次解析结果。
## 3. 1133 条生成式用例分布
### 3.1 按组合类型
| 用例类型 | 数量 | 说明 |
| --- | ---: | --- |
| atomic | 177 | 单条原子指令 |
| pairwise | 300 | 两两组合 |
| pairwise_separator_variant | 300 | 两两组合的不同分隔方式 |
| three_command | 84 | 三指令组合 |
| four_command | 126 | 四指令组合 |
| cross_can0_can1 | 120 | CAN0/CAN1 跨通道组合 |
| multi_command_scene | 10 | 常用多指令场景 |
| safety_rejection | 16 | 白名单外和不安全输入拒绝 |
| **合计** | **1133** | |
### 3.2 按 CAN 通道
| 预期通道 | 数量 | 说明 |
| --- | ---: | --- |
| CAN0 | 13 | 副驾座椅单通道控制 |
| CAN1 | 899 | 车身、空调、主驾座椅、灯光等控制 |
| CAN0 + CAN1 | 205 | 副驾座椅与车身功能组合控制 |
| none | 16 | 安全拒绝,不应生成控制指令 |
| **合计** | **1133** | |
## 4. 代表性解析结果
| 自然语言输入 | 通道 | 预期 CAN 语义控制指令/操作 | 结果 |
| --- | --- | --- | --- |
| 后排车门上锁 | CAN1 | `door rear lock` | 通过 |
| 打开左前车窗 | CAN1 | `window front-left open` | 通过 |
| 副驾驶座椅加热开到2档 | CAN0 | `seat.set_heating(state=on, level=2)` | 通过 |
| 后排车门上锁,打开左前车窗,副驾驶座椅加热开到2档 | CAN0 + CAN1 | CAN1: `door rear lock`; CAN1: `window front-left open`; CAN0: `seat.set_heating(state=on, level=2)` | 通过 |
| 开启木马模式并打开氛围灯 | CAN0 + CAN1 | CAN0: `seat.start_dynamic_mode(mode_id=1)`; CAN1: `light ambient on` | 通过 |
| 开启动态摇篮模式,空调24度,打开遮阳帘 | CAN0 + CAN1 | CAN0: `seat.start_dynamic_mode(mode_id=2)`; CAN1: `ac temp driver 24`; CAN1: `shade open` | 通过 |
| 副驾座椅前后调到20%、坐盆调到30%、靠背调到40%、腿托调到50%,空调风量3档,关闭全部车窗 | CAN0 + CAN1 | CAN0: 四轴绝对位置 20/30/40/50; CAN1: `ac fan 3`; CAN1: `window all close` | 通过 |
| 副驾靠背向后移动15%,主驾温度设为25度,遮阳帘打开 | CAN0 + CAN1 | CAN0: 靠背相对后移 15%; CAN1: `ac temp driver 25`; CAN1: `shade open` | 通过 |
| 主驾座椅通风4档 | none | 白名单外,不生成 CAN 指令 | 通过 |
| 主驾进入零重力模式 | none | 未验证能力,不生成 CAN 指令 | 通过 |
## 5. 6 条新增定向 CAN0/CAN1 用例
| # | 自然语言 | CAN0 结果 | CAN1 结果 |
| ---: | --- | --- | --- |
| 1 | 把副驾座椅水平位置调到35%,空调温度设置为24度,打开氛围灯 | 水平绝对位置 35% | `ac temp driver 24`; `light ambient on` |
| 2 | 副驾坐盆向上调整10%,关闭后排车窗,空调风量2档 | 坐盆相对上调 10% | `window rear close`; `ac fan 2` |
| 3 | 开启木马模式并打开氛围灯 | 木马模式 | `light ambient on` |
| 4 | 开启动态摇篮模式,空调24度,打开遮阳帘 | 摇篮模式 | `ac temp driver 24`; `shade open` |
| 5 | 副驾座椅前后调到20%、坐盆调到30%、靠背调到40%、腿托调到50%,空调风量3档,关闭全部车窗 | 四轴绝对位置 | `ac fan 3`; `window all close` |
| 6 | 副驾靠背向后移动15%,主驾温度设为25度,遮阳帘打开 | 靠背相对后移 15% | `ac temp driver 25`; `shade open` |
以上 6 条用例全部通过。
## 6. 覆盖能力
- CAN0:副驾座椅加热、通风、绝对位置、相对位置、木马模式和摇篮模式。
- CAN1:门锁、车窗、空调、风口、主驾座椅、室内灯、阅读灯、氛围灯和遮阳帘。
- 组合解析:两指令、三指令、四指令、场景化多指令以及 CAN0/CAN1 跨通道组合。
- 位置语义:主驾、副驾、左前、右前、左后、右后、前排、后排。
- 安全约束:越界档位、未验证能力和白名单外目标不生成 CAN 控制流。
## 7. 范围和限制
1. 本报告验证的是“自然语言 → CAN 语义控制指令/操作”解析层。
2. 本次使用 `dry_run`,未向真实 CAN0/CAN1 总线或 MQTT Broker 下发报文。
3. 通过本报告不等于实车执行器、USB-CAN 适配器、MQTT 转发和 ECU 物理动作已完成端到端验证。
4. 如需验证真实执行,应单独进行有线 CAN 和无线 MQTT 台架测试,并记录原始报文、状态回馈和物理动作。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
# 自然语言转 CAN 控制指令组合测试说明
- 生成日期:2026-08-24
- 总用例数:**1133**
- 执行方式:本地规则、`dry_run`,不发送真实 CAN 帧
- 完整逐条清单:`docs/testing/自然语言转CAN组合测试用例.csv`
- 机器可执行数据:`tests/fixtures/natural_can_command_combination_suite.json`
## 覆盖数量
| 用例类型 | 数量 |
| --- | ---: |
| `atomic` | 177 |
| `cross_can0_can1` | 120 |
| `four_command` | 126 |
| `multi_command_scene` | 10 |
| `pairwise` | 300 |
| `pairwise_separator_variant` | 300 |
| `safety_rejection` | 16 |
| `three_command` | 84 |
| 通道 | 数量 |
| --- | ---: |
| `can0` | 13 |
| `can0+can1` | 205 |
| `can1` | 899 |
| `none` | 16 |
## 组合策略
1. 原子用例覆盖 CAN1 门锁、车窗、空调、风口、主驾座椅、灯光、遮阳帘,以及 CAN0 副驾座椅。
2. 两两组合从 25 个互不冲突的控制槽中取组合,同时覆盖中文逗号和分号分隔形式。
3. 三指令和四指令组合覆盖 CAN0/CAN1、座椅、空调、灯光、车窗、门锁、风口与遮阳帘的交叉。
4. 跨通道组合明确检查副驾座椅留在 CAN0、主驾和车身控制留在 CAN1。
5. 多指令场景覆盖观影、双座椅、全温区空调、泊车关闭、车窗与遮阳帘等常用场景。
6. 安全用例要求白名单外目标、档位、能力和矛盾指令不生成 CAN 控制流。
## 执行
```bash
PYTHONPATH=src:domains/seat/src .venv/bin/python -m pytest -q tests/integration/test_natural_can_command_combinations.py
```
重新生成清单:
```bash
.venv/bin/python tools/generate_natural_can_command_suite.py
```
+208
View File
@@ -0,0 +1,208 @@
# can_read_trans 双通道检测与控制
`can_read_trans` 是当前项目的 Linux C++ 命令行诊断程序。一个进程独占一台
ZLG USBCANFD-200U,并同时启动两个通道:
| 通道 | 用途 | 总线格式 | 状态依据 | 控制依据 |
| --- | --- | --- | --- | --- |
| CAN0 | 右前/副驾座椅 | Classic CAN,500 kbit/s,11 位标准 ID | `0x301–0x306/0x4FA` | 副驾固件原生 `0x300/0x29E/0x3CE` |
| CAN1 | 其余车辆设备 | 仲裁 500 kbit/s、数据 2 Mbit/s,Classic CAN + CAN FD/BRS | N7 CAN1 矩阵反馈 | 当前 CAN1 语义白名单 |
CAN0 的运动、通风、加热、木马和摇篮报文来自
`/home/orin/project511/passenger_seat_firmware`。程序不会把旧车机
`0x6A0/0x6A2` 当作控制协议。CAN1 的字节编译和反馈解析复用
`/home/orin/project511/project-code/canfd/can_read_trans.cpp`,避免维护另一份
可能漂移的 CAN1 实现。
## 构建和离线自检
```bash
make can-read-trans
make can-read-trans-test
```
生成的程序位于:
```text
build/can_read_trans/can_read_trans
```
如 CAN 库不在默认目录,可在构建时覆盖:
```bash
make can-read-trans CAN_READ_TRANS_CANFD_ROOT=/path/to/canfd
```
离线自检只核对 ZLG ABI、CAN0 固件字段字节序、动态模式 payload、CAN1 已验证
车窗 payload 和白名单拒绝规则,不打开 USB 设备,也不发送 CAN 帧:
```bash
build/can_read_trans/can_read_trans self-test
```
## 状态检测
持续监听两个通道,按 Ctrl-C 退出:
```bash
build/can_read_trans/can_read_trans monitor
```
监听 5 秒并打印最近状态汇总:
```bash
build/can_read_trans/can_read_trans status --duration-ms 5000
```
只跟踪 CAN0 或 CAN1 的每一条原始接收帧:
```bash
build/can_read_trans/can_read_trans monitor --channel can0 --trace-all
build/can_read_trans/can_read_trans monitor --channel can1 --trace-all
```
`--trace-all` 强制只读,不能与 `send` 组合。程序同时轮询 CAN1 的
`VCI_Receive` 和 `VCI_ReceiveFD`,不会因为只读了其中一个 SDK 队列而漏掉帧。
## 持续交互 Console
只读进入交互终端,可以持续查看状态和校验命令,但不会发送控制帧:
```bash
build/can_read_trans/can_read_trans console
```
确认台架净空、人员远离机构且急停可用后,显式允许本次会话发送白名单命令:
```bash
build/can_read_trans/can_read_trans console --execute
```
程序在同一进程中持续持有 CAN0/CAN1,后台接收线程不会在执行命令时停止,但只
缓存解析结果,不会把周期状态持续打印到交互终端。输入一次 `status` 才会打印一次
当时缓存的 CAN0/CAN1 状态快照。交互命令会根据两份白名单自动路由,不需要手工
添加通道前缀:
```text
can[all]> status
can[all]> seat passenger backrest position 35
[route] CAN0 <- seat passenger backrest position 35
can[all]> ac fan 2
[route] CAN1 <- ac fan 2
can[all]> outlet driver-left close
[route] CAN1 <- outlet driver-left close
can[all]> batch seat passenger basin relative up 10 ; ac fan 2 ; light ambient on
[batch validated] commands=3 execution=sequential stop-on-hard-failure
can[all]> whitelist
can[all]> mark before-shade-test
can[all]> quit
```
内置交互命令:
- `status`:一次性显示最近解析出的 CAN0/CAN1 状态,不发送查询帧;
- `whitelist`:显示全部可发送语义命令;
- `help`:显示 Console 帮助;
- `mark <文本>`:打印带时间戳的调试标记;
- `quit`/`exit`:复位两个通道、关闭设备并退出。
批量命令使用 `batch`,各条语义命令用英文分号分隔:
```text
batch seat passenger basin relative up 10 ; ac fan 2 ; light ambient on
```
程序先完成整批预校验,再发送任何帧。任意一条超出白名单、路由到被
`--channel` 禁止的通道、命令为空,或同批混用 CAN0 动态模式启动与 CAN0 轴移动,
都会拒绝整批且不发送帧。预校验通过后严格按输入顺序串行执行;构造、安全门禁或 SDK
提交失败时立即停止,后续命令不再发送。单风口开关的 3 帧全部提交成功、但在 1500 ms
内未收到 `0x4BB/0x37B` 匹配反馈时,记为 `submitted-unverified`,不误报实体动作成功,
但会继续下一条相互独立的批量命令。最终汇总分别打印 `submitted`、`verified`和
`unverified`。其他依赖闭环或前置状态的命令仍保持原有失败停止策略。单批最多 32 条,
每条最多 160 个字符。
CAN1 的 129 条规范/边界白名单命令已在离线自测中拆成 5 批全部通过;另有一批
30 条的混合用例覆盖门锁、车窗、空调、循环、单风口开关、风口位置、主驾座椅、灯光和遮阳帘。
这证明它们能共同通过批量语义校验并按 CAN1 路由,不等同于无条件完成实体动作:
单窗位置需要新鲜 `0x388`,风口标定坐标需要有效标定,座椅记忆需要 PAD 学习值和
新鲜 `0x494`,遮阳帘位置需要 `0x41A` 闭环;运行时前置条件不满足会停止该批次。
可以用 `--channel can0` 或 `--channel can1` 限制本次会话。路由到未选择通道的
命令会被拒绝。`console --trace-all` 是只读全帧跟踪模式;为防止抓包过程中误发,
它不能与 `--execute` 同时使用。
## 白名单控制
先查看程序接受的完整语义命令:
```bash
build/can_read_trans/can_read_trans whitelist
```
发送必须同时显式给出 `send`、目标通道、白名单语义命令和 `--execute`。例如:
```bash
build/can_read_trans/can_read_trans send \
--channel can0 \
--command "seat passenger backrest position 35" \
--execute
build/can_read_trans/can_read_trans send \
--channel can1 \
--command "ac fan 2" \
--execute
```
以上命令会操作真实硬件,执行前必须确认台架净空、人员远离机构、急停可用,且
车辆满足当前项目的安全条件。程序没有 `--id/--data` 一类任意原始帧发送入口;
不在 CAN0 或 CAN1 语义白名单中的命令会在构造帧前被拒绝。
CAN0 相对移动不会直接信任此前无线链路生成的原始帧:
1. 先读取新鲜、有效的 `0x302` 当前位置;
2. 计算限制在 0–100% 内的绝对目标;
3. 采用固件原生 `0x300` 闭环执行;
4. 只有当前位置不可用且相对量不超过该轴 15% 时,才使用固件限定时长和 Duty
的 Opcode `0x03` 回退;程序不直接暴露 Opcode `0x02`;
5. 未收到终态反馈时发送固件原生 STOP_ALL 安全帧。
当前台架已实测 `seat passenger basin relative up/down` 的物理方向与仓库中旧坐标
说明相反,因此本 C++ 工具把 `up` 映射为 CAN0 归一化位置增加、`down` 映射为减少。
此修正只作用于副驾 CAN0,不会改变 CAN1 主驾坐盆命令或任何 CAN1 帧。
热舒适命令必须先收到新鲜有效的 `0x4FA`,用它保留未被本次命令修改的另一项
功能,防止“调通风”意外关闭加热,或反之。
CAN0 固件要求除 `STOP_ALL` 外的 16 位 sequence 按有符号环形顺序递增。工具重启
后第一次控制若收到 `0x301 RejectReason=8 (SEQUENCE_OLD)`,程序不会猜测或遍历
序号,而是使用固件定义的 `STOP_ALL (0x09)` 序号同步屏障;确认 ACK 且四轴均停止
后,只重试原语义命令一次。若同步或重试仍失败,则保持 Console 可用并停止自动
重试,避免与另一个 CAN0 命令源争抢序号所有权。
## 反馈判定
程序明确区分四个层级:
1. `VCI_Transmit/VCI_TransmitFD=1`:SDK/适配器完成提交;
2. CAN ACK:至少一个正常节点确认该帧;
3. 协议反馈:CAN0 的 `0x301/0x302/0x306/0x4FA` 或 CAN1 对应状态帧出现;
4. 实体动作:机构或车辆设备确实完成动作。
SDK 返回 1 不能单独证明目标 ECU 接收或执行。CAN0 命令会匹配新的 Sequence、
Opcode 和后续状态代次;CAN1 保留既有矩阵反馈等待逻辑。最终仍应结合实体动作、
故障位和总线抓包判断。
## 设备占用和终端电阻
ZLG 设备应只有一个持有进程。运行本工具前,先停止正在占用同一 USB-CAN 盒的
Web 后端、旧版 `can_read_trans` 或 CANPro;否则会出现 `VCI_OpenDevice` 失败。
两个通道的适配器内部 120 Ω 默认都关闭。只有确认适配器位于对应总线物理端点且
拓扑确实缺少该端终端时,才显式增加:
```bash
build/can_read_trans/can_read_trans monitor --can0-termination
build/can_read_trans/can_read_trans monitor --can1-termination
```
不要把“收不到帧”直接解释为“需要打开终端电阻”;应先核对供电、共地、CAN_H、
+41
View File
@@ -0,0 +1,41 @@
# CAN0/CAN1 共享语义协议清单
CAN 语义控制的单一来源是:
- `config/can_protocol/semantic_protocol.json`
该清单同时定义 CAN0/CAN1 的语义语法、能力映射、帧 ID、经典 CAN/CAN FD、BRS、DLC、基础帧模板、固定或动态重复策略、周期和反馈 ID。需要实时状态参与的窗口位置、遮阳帘位置、座椅相对移动和记忆位置仍由命名的 `special_handler` 执行闭环算法;这些算法不得另行定义帧 ID、重复节拍或反馈来源。
## 生成物
不要直接修改以下文件:
- `src/cognitive_agent/generated/can_semantic_protocol.py`
- `tools/can_read_trans/generated/can_semantic_protocol.hpp`
更新清单后执行:
```bash
make can-protocol-generate
```
只检查生成物是否同步:
```bash
make can-protocol-check
```
`make validate`、`make test` 和 `make can-read-trans-test` 都会检查或重新生成协议视图。生成内容携带规范化源文件的 SHA-256,便于确认 Python 与 C++ 来自同一版本。
## 运行时接入
- Python CAN1 `NativeCanRuntime` 从生成模块取得支持能力、帧格式、帧 ID、重复策略和反馈 ID。
- Python CAN0 `T2GSeatCompiler` 在每个物理命令中附带生成的协议契约;`HardwareSeatExecutor` 会在发送前校验矩阵编码的 ID、DLC 和格式,并使用契约中的反馈 ID。
- C++ `can_read_trans` 使用生成头文件匹配 CAN0/CAN1 白名单、打印帮助、取得 CAN0 传输元数据和 CAN1 单风口精确 payload。其自检还会将生成模板与已验证的状态闭环编码器交叉比对。
## 修改规则
1. 仅在协议证据明确时修改帧模板、重复次数或反馈 ID。
2. 新增命令时必须同时填写 `patterns`、`frames`、`repeat` 和 `feedback_ids`。
3. 有状态序列使用 `special_handler`,但传输元数据仍放在共享清单中。
4. 先执行离线生成和测试;真实总线验证必须另外明确授权,且分别记录 SDK 提交、CAN ACK、应用反馈和实体动作。
+34 -24
View File
@@ -1,10 +1,10 @@
# Linux ZLG 车辆状态接入
当前工程可以通过 ZLG Linux VCI SDK 被动读取 USBCANFD-200U 的 CH1,并把
车辆状态送入 `/api/hardware/vehicle-state` 和前端整车孪生。状态轮询使用独立
`monitor-only` 桥,桥进程会拒绝所有发送请求。服务启动后由一个常驻监听线程
持续缓存最新的已验证状态,HTTP 接口不会为每次前端请求反复开关 USB 设备。
Linux 只读状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒内的新鲜反馈,
当前工程可以通过 ZLG Linux VCI SDK 使用 USBCANFD-200U 的 CAN0/CAN1,并把
车辆状态送入 `/api/hardware/vehicle-state` 和前端整车孪生。只读配置使用独立
`monitor-only` 桥,桥进程会拒绝所有发送请求;有线控制配置则在一次请求内由
同一桥进程共同持有 CAN0/CAN1,完成安全预检、控制和反馈后关闭。Linux 只读
状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒内的新鲜反馈,
断线后会自动关闭旧桥并重试连接。监听通道只接受本文列出的 12 个标准帧 ID,
同时在 SDK 桥内保留同样的软件过滤,避免把无关总线流量转发给服务进程。
@@ -19,6 +19,11 @@ Linux 只读状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒
- 数据段:2 Mbit/s,`tseg1=10, tseg2=2, sjw=2, brp=1`
- 终端:默认不启用适配器内部 120 欧;只能按实际拓扑调整
有线真实执行采用一个设备 owner 同时初始化两个通道:CAN0 为最新副驾座椅
经典 CAN 500 kbit/s,CAN1 为车辆 ISO CAN FD 500 kbit/s / 2 Mbit/s。副驾控制
使用 `0x300/0x29E/0x3CE`,状态反馈覆盖 `0x301–0x306/0x4FA`。Linux CAN0
不依赖旧 IAR profile、自动烧录或 T2G combined 固件检查。
前端车辆概况依赖 `0x342`(档位)、`0x4EB/0x07`(车速)和 `0x407`
(门锁)。主驾座椅依赖 `0x4FA`、`0x494`。收到其他帧不能替代这些已验证
反馈。车身设备还会按帧独立更新:`0x388` 四窗、`0x404` 空调、`0x41A`
@@ -28,7 +33,7 @@ Linux 只读状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒
前端“车辆连接方式”提供两个相互独立的传输选项:
- 有线控制:状态和控制都走后端 ZLG CH1。
- 有线控制:副驾座椅状态和控制走后端 ZLG CAN0,其余车辆状态和控制走 CAN1。
- 无线控制:状态订阅 `n7/n7-headunit/can/state`,控制发布到
`n7/n7-headunit/can/command`;默认 Broker 为 `127.0.0.1:1883`。MQTT CAN0 专用于
副驾座椅,MQTT CAN1 专用于除副驾座椅外的车辆状态和控制。
@@ -89,27 +94,32 @@ FD+BRS。车机按通道、控制 ID 和帧类型推断预期反馈 ID。
无线通道边界为:
- CAN0:经典 CAN 副驾座椅;直接状态接收 `0x301–0x307`、`0x4FA`。当前车端
- CAN0:经典 CAN 副驾座椅;直接状态接收 `0x301–0x306`、`0x4FA`。当前车端
还会以只读 `0x6A0` `SEAT_INFO(0x60)` 查询和 `0x6A2` 响应上报四轴 Hall
位置、范围和标志。后端只配对 `C7/7C`、相同序号、`status=0` 的查询响应,
再转换成统一的四轴 `0x302` 状态;查询帧不会被当作普通动作执行。
无线副驾控制只允许 `0x6A0` 中固件明确提供的运行时 opcode:
`STOP(0x63)`、`MOVE(0x64)`、`HEARTBEAT(0x65)` 和
`DYNAMIC_START(0x66)`,并等待匹配序号/opcode 的 `0x6A2` 成功响应及新鲜
四轴位置反馈。刷写、进入 Bootloader、测试、校准和回零 opcode 全部拒绝;
旧 `0x29E/0x300/0x3CE` 不会在该无线 CAN0 路径发布。
无线副驾控制直接发布与有线相同的固件原生帧:运动/停止/木马/摇篮使用
`0x300`,通风/加热使用 `0x29E/0x3CE`;运动等待 `0x301/0x302`,动态模式
等待 `0x301/0x306`,热舒适等待 `0x4FA`。`0x300` 只开放语义运行时子集
`0x01/0x02/0x03/0x08/0x09/0x0B/0x0E/0x0F`,并逐字段校验版本、轴、方向、
sequence、ParamA 和 ParamB。刷写、进入 Bootloader、测试、全量程校准、查询
和清故障全部拒绝;`0x6A0` 不再允许作为无线控制帧发布。
- CAN1:除副驾座椅以外的整车 CAN;状态和控制继续使用项目中已有的 CH1
车辆白名单。CAN0 控制 ID 不能经 CAN1 发送,CAN1 控制 ID 也不能经 CAN0
发送。
两个通道的协议来源严格分离:
- CAN0 只以 `/home/orin/project511/seat-code-file/seat-ota` 副驾固件为准;
- CAN0 只以 `/home/orin/project511/passenger_seat_firmware/firmware` 与
`/home/orin/project511/passenger_seat_firmware/Passenger_Seat_Controller_demo`
中的副驾固件协议为准;
入站状态必须是 11 位标准帧、经典 CAN、无 BRS、DLC 8。
- CAN1 只以
`/home/orin/project511/project-code/canfd/CAN矩阵表_filled.xlsx` 为准。
例如 `0x42E` 颜色值只有 `0x01–0x1F` 有效,保留值不会显示为
真实颜色。`0x388` 的四窗关好位按矩阵解析;byte 3–6 的四窗百分比另有
- CAN1 以
`/home/orin/project511/project-code/canfd/CAN矩阵表_filled.xlsx` 和已验证的
N7 车型能力契约为准。`0x42E` 的颜色字段仍按矩阵的 7 位布局解码;通用矩阵
将 `0x20–0x7F` 标记为预留,但 N7 的 127 色车型配置、控制白名单与实车 HIL
均使用 `0x01–0x7F`,因此保留完整车型色号用于显示和反馈匹配,`0x00` 仍视为
无效。该帧不包含 RGB 值,前端不得把色号伪装成精确颜色。`0x388` 的四窗关好位按矩阵解析;byte 3–6 的四窗百分比另有
`project-code/canfd/window_native.txt` 实车变化轨迹和车端原始帧证据,
因此只有数值落在 `0–100` 时才显示为开度。
@@ -124,8 +134,8 @@ MQTT 消息自带的 `can0Seat.parsed` 和 `can1Vehicle.parsed` 只可用于车
每个 MQTT 总线视图只会收到本通道反馈。发布完成仅代表 Broker 接收消息;
座椅和整车执行器仍必须等待对应状态 ID 与目标字段匹配,才会报告动作成功。
当前 seat-ota 固件只提供四轴位置和动态模式控制,没有副驾加热/通风运行时
opcode;无线模式收到这两类副驾请求会在发布前明确失败,不会伪造反馈。
副驾加热/通风使用固件已有的 `0x3CE/0x29E` 控制和 `0x4FA` 反馈;无线模式
与有线模式共享同一套原始帧、参数白名单和反馈闭环,仅传输载体不同。
车端现有 `n7-can-signals/v1` 状态封装也可直接发布到
`n7/n7-headunit/can/state`。后端优先从顶层 `signals` 读取,避免与嵌套副本重复;
@@ -170,11 +180,11 @@ getfacl -p /dev/bus/usb/001/006
## 服务配置
将 `config/environments/linux_vehicle_state.example.env` 中的配置复制到仓库
根目录 `.env`,并按实际 SDK 位置修改
`COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY`。不要设置硬件批准令牌;此时
`vehicle_can.state_configured=true`,但 `vehicle_can.configured=false`,所以只
允许状态同步,前端真实执行保持锁定。
只读 CH1 状态监听使用 `config/environments/linux_vehicle_state.example.env`。
CAN0/CAN1 有线控制使用
`config/environments/linux_wired_dual_channel.example.env`,复制到仓库根目录
`.env` 后按实际 SDK 位置修改 `COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY`。本地有人
值守台架可以显式关闭逐请求令牌;共享或远程环境应恢复令牌门禁。
启动:
@@ -27,7 +27,7 @@ spec:
- name: color_hex
type: string
required: true
description: RGB color encoded as #RRGGBB; infer a suitable color for semantic requests.
description: "RGB color encoded as #RRGGBB; infer a suitable color for semantic requests."
- name: brightness_pct
type: integer
unit: percent
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
- name: climate.set_temperature
description: Set an absolute HVAC temperature from 18 to 32 degrees Celsius.
target_devices: [hvac]
target_scopes: [driver, passenger, front_row, rear_row, rear_left, rear_right, vehicle]
target_scopes: [driver, passenger, front_row, rear, rear_row, rear_left, rear_right, vehicle]
parameters:
- name: temperature_c
type: number
+12 -5
View File
@@ -9,23 +9,30 @@ from .executor import (
from .generator import build_function_config, build_function_preset, build_intent_ir
from .hardware_compiler import T2GSeatCompiler
from .hardware_executor import HardwareExecutionError, HardwareSeatExecutor
from .intent import parse_local_intent, resolve_contextual_intent
from .intent import (
SeatEndpointResolution,
parse_local_intent,
resolve_contextual_intent,
resolve_seat_axis_endpoint,
)
from .matrix import SeatCanMatrix
from .validator import validate_semantics
__all__ = [
"SeatCanMatrix",
"SeatCompiler",
"T2GSeatCompiler",
"SIMULATED_HARDWARE_FAULTS",
"HardwareExecutionError",
"HardwareSeatExecutor",
"SIMULATED_HARDWARE_FAULTS",
"SeatCanMatrix",
"SeatCompiler",
"SeatEndpointResolution",
"SimulatedHardwareSeatExecutor",
"T2GSeatCompiler",
"VirtualSeatExecutor",
"build_function_config",
"build_function_preset",
"build_intent_ir",
"parse_local_intent",
"resolve_contextual_intent",
"resolve_seat_axis_endpoint",
"validate_semantics",
]
+12
View File
@@ -395,6 +395,9 @@ def build_function_config(
"capability": DYNAMIC_MODE_STOP_CAPABILITY,
"target": TARGETS[action["target"]],
"parameters": {},
"parse_source": str(
action.get("parse_source", "local_rule")
),
"timeout_ms": 5000,
"failure_policy": "abort",
}
@@ -412,6 +415,9 @@ def build_function_config(
"capability": DYNAMIC_MODE_CAPABILITY,
"target": TARGETS[action["target"]],
"parameters": {"mode_id": int(action["mode_id"])},
"parse_source": str(
action.get("parse_source", "local_rule")
),
"timeout_ms": 5000,
"failure_policy": "abort",
}
@@ -437,6 +443,9 @@ def build_function_config(
"capability": MOTION_CAPABILITIES[action["axis"]],
"target": TARGETS[action["target"]],
"parameters": parameters,
"parse_source": str(
action.get("parse_source", "local_rule")
),
"timeout_ms": 8000,
"failure_policy": "abort",
}
@@ -460,6 +469,9 @@ def build_function_config(
"capability": CAPABILITIES[action["function"]],
"target": TARGETS[action["target"]],
"parameters": parameters,
"parse_source": str(
action.get("parse_source", "local_rule")
),
"timeout_ms": 3000,
"failure_policy": "abort",
}
@@ -9,6 +9,11 @@ from typing import Any
import yaml
from cognitive_agent.generated.can_semantic_protocol import (
CAN0_SUPPORTED_CAPABILITIES,
command_contract as shared_protocol_contract,
)
STATE_VALUES = {"on": 1, "off": 2}
@@ -16,7 +21,7 @@ STATE_VALUES = {"on": 1, "off": 2}
class T2GSeatCompiler:
"""Deterministic compiler for the HIL-verified T2G seat bench protocol."""
version = "2.1.0"
version = "2.2.0"
def __init__(self, mapping_path: Path, calibration_path: Path):
self.mapping = yaml.safe_load(mapping_path.read_text(encoding="utf-8"))
@@ -29,6 +34,10 @@ class T2GSeatCompiler:
for item in self.mapping["spec"]["mappings"]
}
@staticmethod
def _protocol_contract(rule_id: str, key: str) -> dict[str, Any]:
return shared_protocol_contract(rule_id, key)
def compile(
self,
function_config: dict[str, Any],
@@ -128,6 +137,9 @@ class T2GSeatCompiler:
"operation_ref": operation_ref,
"bus": "seat_can",
"message": "T2G_Seat_Thermal_Mode_3CE",
"protocol": self._protocol_contract(
"can0.seat.thermal", "thermal_mode"
),
"signals": {"Driver_Seat_Thermal_Mode": 1},
"send_offset_ms": 0,
}
@@ -138,6 +150,9 @@ class T2GSeatCompiler:
"operation_ref": operation_ref,
"bus": "seat_can",
"message": "T2G_Seat_Thermal_Control_29E",
"protocol": self._protocol_contract(
"can0.seat.thermal", "thermal_control"
),
"signals": {
"Driver_Seat_Heating_Gear": int(desired["heating"]["level"]),
"Driver_Seat_Heating_On": STATE_VALUES[desired["heating"]["state"]],
@@ -232,6 +247,15 @@ class T2GSeatCompiler:
),
"bus": "seat_can",
"message": "T2G_Seat_Move_Command_300",
"protocol": self._protocol_contract(
(
"can0.seat.relative"
if (operation.get("motion") or {}).get("mode")
in {"relative", "timed"}
else "can0.seat.position"
),
"move",
),
"signals": {
"Direction": 0,
"Motor_Selector": int(axis["motor_selector"]),
@@ -292,6 +316,9 @@ class T2GSeatCompiler:
"operation_ref": operation["id"],
"bus": "seat_can",
"message": "T2G_Seat_Move_Command_300",
"protocol": self._protocol_contract(
"can0.seat.dynamic", "dynamic"
),
"signals": {
"Direction": 0,
"Motor_Selector": 0,
@@ -357,6 +384,7 @@ class T2GSeatCompiler:
),
"bus": "seat_can",
"message": "T2G_Seat_Move_Command_300",
"protocol": self._protocol_contract("can0.seat.relative", "move"),
"signals": {
"Direction": direction,
"Motor_Selector": int(axis["motor_selector"]),
@@ -426,6 +454,7 @@ class T2GSeatCompiler:
),
"bus": "seat_can",
"message": "T2G_Seat_Move_Command_300",
"protocol": self._protocol_contract("can0.seat.relative", "move"),
"signals": {
"Direction": direction,
"Motor_Selector": int(axis["motor_selector"]),
@@ -469,6 +498,10 @@ class T2GSeatCompiler:
def _require_supported(self, operation: dict[str, Any]) -> None:
key = (operation["capability"], operation["target"]["zone"])
if key[0] not in CAN0_SUPPORTED_CAPABILITIES:
raise ValueError(
f"shared CAN0 protocol does not support capability={key[0]}"
)
if key not in self.supported:
raise ValueError(
"T2G hardware mapping does not support "
+513 -29
View File
@@ -9,6 +9,9 @@ from typing import Any, Callable
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
from cognitive_agent.infrastructure.firmware import T2GFirmwareGuard
from cognitive_agent.generated.can_semantic_protocol import (
command_contract as shared_protocol_contract,
)
from .executor import MOTION_CAPABILITY_TO_AXIS
from .matrix import SeatCanMatrix
@@ -114,6 +117,7 @@ class HardwareSeatExecutor:
synchronize_position_before_first_motion: bool = False,
position_sync_state: dict[str, bool] | None = None,
require_thermal_feedback: bool = True,
feedback_required: bool = True,
):
self.matrix = matrix
self.bus_factory = bus_factory
@@ -136,7 +140,19 @@ class HardwareSeatExecutor:
else {"synchronized": False}
)
self.require_thermal_feedback = bool(require_thermal_feedback)
# A locally attached CAN adapter can synchronously enforce the complete
# 0x301/0x302/0x306/0x4FA feedback contract. The wireless head-unit
# relay currently publishes CAN0 state asynchronously, so its HTTP
# execution path must stop after the approved frame has entered MQTT.
# Keep this opt-out explicit and default-on so wired HIL retains its
# strict physical verification behavior.
self.feedback_required = bool(feedback_required)
self._current_samples: dict[int, list[dict[str, Any]]] = {}
# Evidence for the currently executing *requested* command. Safety
# prerequisites and best-effort STOP frames are deliberately excluded,
# so a feedback timeout can be reported as submitted-but-unverified
# without mistaking a prerequisite frame for the requested operation.
self._active_command_frame: CanFrame | None = None
@classmethod
def _next_sequence(cls) -> int:
@@ -145,6 +161,49 @@ class HardwareSeatExecutor:
cls._sequence = 1
return cls._sequence
@staticmethod
def _operation_debug_command(operation: dict[str, Any]) -> str:
"""Return the CAN0 semantic command attached to MQTT debug metadata."""
capability = str(operation["capability"])
parameters = operation.get("parameters", {})
axes = {
"seat.set_longitudinal_position": "horizontal",
"seat.set_cushion_tilt": "basin",
"seat.set_backrest_recline": "backrest",
"seat.set_legrest_height": "legrest",
}
if capability in axes:
axis = axes[capability]
motion = operation.get("motion", {})
relative_directions = {
"horizontal": {"increase": "backward", "decrease": "forward"},
"basin": {"increase": "down", "decrease": "up"},
"backrest": {"increase": "backward", "decrease": "forward"},
"legrest": {"increase": "up", "decrease": "down"},
}
direction = relative_directions[axis].get(
str(motion.get("direction", ""))
)
delta = motion.get("delta_position_pct")
if motion.get("mode") == "relative" and direction and delta is not None:
return f"seat passenger {axis} relative {direction} {int(delta)}"
return f"seat passenger {axis} position {parameters['position_pct']}"
if capability in {"seat.set_ventilation", "seat.set_heating"}:
feature = "vent" if capability.endswith("ventilation") else "heat"
if parameters.get("state") == "off":
return f"seat passenger {feature} off"
level = {1: "low", 2: "medium", 3: "high"}.get(
int(parameters.get("level", 1)), "low"
)
return f"seat passenger {feature} {level}"
if capability == "seat.start_dynamic_mode":
mode = "rocking-horse" if int(parameters["mode_id"]) == 1 else "cradle"
return f"seat passenger dynamic {mode}"
if capability == "seat.stop_dynamic_mode":
return "seat passenger dynamic stop"
return capability
def execute(
self,
command_plan: dict[str, Any],
@@ -169,6 +228,8 @@ class HardwareSeatExecutor:
position_sync_deferred_to_commands = False
position_sync_trigger = "process_first_motion"
physical_state: dict[str, Any] | None = None
motion_publication_start: int | None = None
thermal_publication_start: int | None = None
self._current_samples = {}
with self._adapter_lock:
@@ -200,7 +261,22 @@ class HardwareSeatExecutor:
else:
bus = self.bus_factory()
if has_motion:
self._stop_all(bus, require_ack=True)
motion_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(operation)
for operation in operations.values()
)
)
# STOP_ALL is the firmware-defined sequence barrier. Keep
# publishing it for a remote relay, but do not block the
# requested command on a synchronous 0x301 that is delivered
# later through the MQTT state stream.
self._stop_all(bus, require_ack=self.feedback_required)
motion_armed = True
position_sync_required = (
self.synchronize_position_before_first_motion
@@ -231,14 +307,37 @@ class HardwareSeatExecutor:
)
previous_offset = 0
for command in commands:
self._active_command_frame = None
offset = int(command.get("send_offset_ms", 0))
if offset > previous_offset:
time.sleep((offset - previous_offset) / 1000)
previous_offset = offset
operation_ids = command["operation_ref"].split(",")
referenced = [operations[item] for item in operation_ids]
publication_start = (
thermal_publication_start
if thermal_publication_start is not None
else motion_publication_start
if motion_publication_start is not None
and command["message"] == "T2G_Seat_Move_Command_300"
else 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_Thermal_Mode_3CE":
frame = self._send_encoded(bus, command["message"], command["signals"])
frame = self._send_encoded(
bus,
command["message"],
command["signals"],
self._resolved_protocol(command),
)
self._active_command_frame = frame
results.append(
{
"ref": command["id"],
@@ -252,19 +351,56 @@ class HardwareSeatExecutor:
},
}
)
thermal_publication_start = publication_start
continue
elif not self.feedback_required:
command_results = self._submit_without_feedback(
bus, command, referenced
)
elif command["message"] == "T2G_Seat_Thermal_Control_29E":
results.extend(self._execute_thermal(bus, command, referenced))
command_results = self._execute_thermal(
bus, command, referenced
)
thermal_publication_start = None
elif command["message"] == "T2G_Seat_Move_Command_300":
if int(command["signals"]["Opcode"]) in {0x0E, 0x0F}:
results.extend(
self._execute_dynamic_mode(bus, command, referenced)
command_results = self._execute_dynamic_mode(
bus, command, referenced
)
else:
results.extend(self._execute_motion(bus, command, referenced))
command_results = self._execute_motion(
bus, command, referenced
)
motion_publication_start = None
else:
raise HardwareExecutionError(
f"hardware executor refuses unknown message {command['message']}"
)
mqtt_messages = list(
getattr(bus, "published_messages", ())[publication_start:]
)
if mqtt_messages:
for item in command_results:
telemetry = item.setdefault("telemetry", {})
telemetry["mqtt_messages"] = mqtt_messages
if not self.feedback_required:
delivery_status = (
"published"
if all(
message.get("delivery_status") == "published"
for message in mqtt_messages
)
else "queued"
)
telemetry["delivery_status"] = delivery_status
item["message"] = (
"MQTT CAN0 白名单控制帧已由 Broker 确认;"
"副驾座椅效果以异步状态回读为准。"
if delivery_status == "published"
else "MQTT CAN0 白名单控制帧已进入客户端发布队列;"
"副驾座椅效果以异步状态回读为准。"
)
results.extend(command_results)
if position_sync_deferred_to_commands:
self.position_sync_state["synchronized"] = True
results.append(
@@ -287,36 +423,129 @@ class HardwareSeatExecutor:
}
)
except Exception as exc: # noqa: BLE001 - normalize transport and protocol failures
if bus is not None and motion_armed:
submitted_frame = self._active_command_frame
feedback_timed_out = (
submitted_frame is not None
and isinstance(exc, HardwareExecutionError)
and str(exc).startswith("timed out after ")
)
if bus is not None and motion_armed and self.feedback_required:
self._best_effort_stop(bus)
operation_ref = self._first_unreported_operation(operations, results)
mqtt_messages = list(getattr(bus, "published_messages", ())) if bus else []
delivery_status = "submitted"
if mqtt_messages:
delivery_status = (
"published"
if all(
item.get("delivery_status") == "published"
for item in mqtt_messages
)
else "queued"
)
results.append(
{
"ref": operation_ref,
"domain": "seat",
"status": "failed",
"message": str(exc),
"status": "succeeded" if feedback_timed_out else "failed",
"message": (
"CAN0 控制帧已提交,但反馈窗口内未完成车辆状态确认;"
"编排提交成功,实体效果仍未验证。"
if feedback_timed_out
else str(exc)
),
"telemetry": {
**getattr(exc, "telemetry", {}),
"error_type": type(exc).__name__,
"stop_all_attempted": bool(bus is not None and motion_armed),
**(
{
"command_frame": submitted_frame.public(),
"delivery_status": delivery_status,
"verification_status": "submitted_unverified",
"physical_effect_confirmed": False,
"feedback_error": str(exc),
**(
{"mqtt_messages": mqtt_messages}
if mqtt_messages
else {}
),
}
if feedback_timed_out
else {
**(
{"command_frame": submitted_frame.public()}
if submitted_frame is not None
else {}
),
"verification_status": "failed_or_blocked",
"physical_effect_confirmed": False,
}
),
"simulated": False,
},
}
)
finally:
if bus is not None:
try:
physical_state = self._capture_physical_state(bus, results)
except Exception: # noqa: BLE001 - do not mask the command outcome
if self.feedback_required:
try:
physical_state = self._capture_physical_state(bus, results)
except Exception: # noqa: BLE001 - do not mask the command outcome
physical_state = self._physical_state_from_results(results)
else:
# MQTT state synchronization runs independently. Do not
# add another bounded receive wait to the control request.
physical_state = self._physical_state_from_results(results)
bus.close()
succeeded = all(item["status"] == "succeeded" for item in results)
for item in results:
telemetry = item.setdefault("telemetry", {})
telemetry.setdefault(
"verification_status",
(
"verified_success"
if item.get("status") == "succeeded"
and (
telemetry.get("feedback_frame") is not None
or telemetry.get("hardware_feedback") is True
or telemetry.get("firmware_verified") is True
)
else "submitted_unverified"
if item.get("status") == "succeeded"
and telemetry.get("command_frame") is not None
else "verified_success"
if item.get("status") == "succeeded"
else "failed_or_blocked"
),
)
telemetry.setdefault(
"physical_effect_confirmed",
telemetry["verification_status"] == "verified_success",
)
if telemetry.get("command_frame") is not None:
telemetry.setdefault("delivery_status", "submitted")
verification_status = (
"failed_or_blocked"
if any(
item.get("telemetry", {}).get("verification_status")
== "failed_or_blocked"
for item in results
)
else "submitted_unverified"
if any(
item.get("telemetry", {}).get("verification_status")
== "submitted_unverified"
for item in results
)
else "verified_success"
)
succeeded = verification_status != "failed_or_blocked"
request_id = function_config["metadata"]["request_id"]
spec: dict[str, Any] = {
"mode": "hil",
"status": "succeeded" if succeeded else "failed",
"verification_status": verification_status,
"started_at": started_at,
"finished_at": _now(),
"results": results,
@@ -336,6 +565,59 @@ class HardwareSeatExecutor:
"spec": spec,
}
def _submit_without_feedback(
self,
bus: CanBus,
command: dict[str, Any],
operations: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Publish one approved CAN0 command without a synchronous state gate.
This mode is reserved for the wireless head-unit relay. It still uses
the same matrix encoder and generated protocol contract as wired HIL,
including a fresh firmware sequence for every 0x300 command. Only the
evidence level changes: submission succeeds, while physical completion
remains unverified until the independent MQTT state stream reports it.
"""
signals = dict(command["signals"])
if command["message"] == "T2G_Seat_Move_Command_300":
signals["Sequence"] = self._next_sequence()
elif command["message"] != "T2G_Seat_Thermal_Control_29E":
raise HardwareExecutionError(
f"wireless CAN0 executor refuses unknown message {command['message']}"
)
command_frame = self._send_encoded(
bus,
command["message"],
signals,
self._resolved_protocol(command),
)
self._active_command_frame = command_frame
return [
{
"ref": operation["id"],
"domain": "seat",
"status": "succeeded",
"message": (
"CAN0 控制帧已提交到无线 MQTT;副驾座椅效果等待异步状态回读。"
),
"telemetry": {
"requested_target": operation["target"]["instance"],
"requested_parameters": operation.get("parameters", {}),
"command_frame": command_frame.public(),
"feedback_required": False,
"feedback_observed": False,
"delivery_status": "submitted",
"verification_status": "submitted_unverified",
"physical_effect_confirmed": False,
"control_gate": "compiled_action_and_frame_whitelist",
"simulated": False,
},
}
for operation in operations
]
def read_physical_state(self) -> dict[str, Any]:
"""Read the passenger seat status without transmitting a CAN command."""
@@ -359,6 +641,7 @@ class HardwareSeatExecutor:
results: list[dict[str, Any]],
) -> dict[str, Any]:
state = self._physical_state_from_results(results)
observed_ids: set[int] = set()
deadline = time.monotonic() + max(
self.thermal_timeout_ms,
self.ack_timeout_ms,
@@ -371,15 +654,102 @@ class HardwareSeatExecutor:
frame = bus.receive(remaining_ms)
if frame is None:
break
if len(frame.data) >= 8 and frame.arbitration_id in {
0x301,
0x302,
0x303,
0x304,
0x305,
0x306,
0x4FA,
}:
observed_ids.add(frame.arbitration_id)
self._observe_auxiliary_frame(frame)
if frame.arbitration_id == 0x302 and len(frame.data) >= 8:
if frame.arbitration_id == 0x301 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x301, frame.data)
state["system"] = {
"ack": int(values["Ack"]),
"state": int(values["System_State"]),
"reject_reason": int(values["Reject_Reason"]),
"last_sequence": int(values["Last_Sequence"]),
"last_opcode": int(values["Last_Opcode"]),
"active_mask": int(values["Active_Mask"]),
"safety_inhibit": bool(values["Safety_Inhibit"]),
"calibration_busy": bool(values["Calibration_Busy"]),
"fault": bool(values["Fault_Summary"]),
"faulted_mask": int(values["Faulted_Mask"]),
"position_valid_mask": int(values["Position_Valid_Mask"]),
"firmware_revision": int(values["Firmware_Revision"]),
"feedback_frame": frame.public(),
}
elif frame.arbitration_id == 0x302 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x302, frame.data)
self._record_motion_state(state, values, frame)
elif frame.arbitration_id == 0x303 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x303, frame.data)
motor = int(values["Motor_Sub_ID"])
if motor in PHYSICAL_MOTOR_AXES:
state.setdefault("diagnostics", {}).setdefault("motors", {})[
PHYSICAL_MOTOR_AXES[motor]
] = {
"hall_count": int(values["Hall_Count"]),
"max_hall_count": int(values["Max_Hall_Count"]),
"hall_speed_half_pps": int(values["Hall_Speed_Half_Pps"]),
"last_event": int(values["Last_Motor_Event"]),
"fault_flags": int(values["Fault_Flags"]),
"retry_count": int(values["Retry_Count"]),
"feedback_frame": frame.public(),
}
elif frame.arbitration_id == 0x304 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x304, frame.data)
motor = int(values["Motor_Sub_ID"])
if motor in PHYSICAL_MOTOR_AXES:
motor_state = state.setdefault("diagnostics", {}).setdefault(
"motors", {}
).setdefault(PHYSICAL_MOTOR_AXES[motor], {})
motor_state["current"] = {
"sample_valid": bool(values["Current_Sample_Valid"]),
"baseline_valid": bool(values["Current_Baseline_Valid"]),
"high_current": bool(values["High_Current"]),
"hall_activity_low": bool(values["Hall_Activity_Low"]),
"calibrating": bool(values["Current_Calibrating"]),
"raw": int(values["Current_Raw"]),
"baseline_raw": int(values["Current_Baseline_Raw"]),
"stall_delta_threshold_raw": int(
values["Stall_Current_Delta_Threshold_Raw"]
),
"feedback_frame": frame.public(),
}
elif frame.arbitration_id == 0x305 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x305, frame.data)
state.setdefault("diagnostics", {})["thermal"] = {
"fan_sample_valid": bool(values["Fan_Current_Sample_Valid"]),
"heater_sample_valid": bool(
values["Heater_Current_Sample_Valid"]
),
"fan_running": bool(values["Fan_Running"]),
"heater_level": int(values["Heater_Level"]),
"fan_current_raw": int(values["Fan_Current_Raw"]),
"heater_current_raw": int(values["Heater_Current_Raw"]),
"fan_adc_error_count": int(values["Fan_Adc_Error_Count"]),
"heater_adc_error_count": int(
values["Heater_Adc_Error_Count"]
),
"feedback_frame": frame.public(),
}
elif frame.arbitration_id == 0x4FA and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x4FA, frame.data)
self._record_thermal_state(state, values, frame)
elif frame.arbitration_id == 0x306 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x306, frame.data)
self._record_dynamic_state(state, values, frame)
state["captured_at"] = _now()
state["source"] = "physical_can"
state["channel"] = 0
state["protocol_profile"] = "passenger-seat-native-can0-v1"
state["observed_frame_ids"] = [
f"0x{frame_id:03X}" for frame_id in sorted(observed_ids)
]
state["target"] = {
"device": "seat",
"zone": "front_right",
@@ -461,6 +831,30 @@ class HardwareSeatExecutor:
self._set_thermal_feature(state, feature, raw)
state[feature]["feedback_frame"] = frame.public()
@staticmethod
def _record_dynamic_state(
state: dict[str, Any],
values: dict[str, int],
frame: CanFrame,
) -> None:
mode_id = int(values["Dynamic_Mode_ID"])
dynamic_state = int(values["Dynamic_State"])
names = {1: "木马模式", 2: "摇篮模式"}
states = {0: "idle", 1: "entry", 2: "running", 3: "fault"}
state["dynamic_mode"] = {
"mode_id": mode_id,
"name": names.get(mode_id, ""),
"state": states.get(dynamic_state, "unknown"),
"phase": int(values["Dynamic_Phase"]),
"active_mask": int(values["Dynamic_Active_Mask"]),
"fault_mask": int(values["Dynamic_Fault_Mask"]),
"cycle_index": int(values["Dynamic_Cycle_Index"]),
"phase_elapsed_ms": int(values["Dynamic_Phase_Elapsed_Ms"]),
"repeat": "bounded_runtime" if dynamic_state in {1, 2} else "none",
"max_runtime_ms": 600_000,
"feedback_frame": frame.public(),
}
@staticmethod
def _set_thermal_feature(
state: dict[str, Any], feature: str, raw: int
@@ -505,10 +899,66 @@ class HardwareSeatExecutor:
return next((item for item in operations if item not in reported), next(iter(operations)))
def _send_encoded(
self, bus: CanBus, message: str, signals: dict[str, int]
self,
bus: CanBus,
message: str,
signals: dict[str, int],
protocol: dict[str, Any] | None = None,
) -> CanFrame:
encoded = self.matrix.encode(message, signals)
return bus.send(int(encoded["frame_id"], 16), bytes.fromhex(encoded["data_hex"]))
frame_id = int(encoded["frame_id"], 16)
payload = bytes.fromhex(encoded["data_hex"])
if protocol is not None:
expected_id = int(str(protocol["frame_id"]), 16)
if frame_id != expected_id:
raise HardwareExecutionError(
f"shared protocol expects 0x{expected_id:03X}, matrix encoded 0x{frame_id:03X}"
)
if protocol["format"] != "classic_can" or protocol["brs"]:
raise HardwareExecutionError(
"CAN0 shared protocol requires classic CAN without BRS"
)
if len(payload) != int(protocol["dlc"]):
raise HardwareExecutionError(
"CAN0 encoded payload length disagrees with shared protocol"
)
return bus.send(frame_id, payload)
@staticmethod
def _resolved_protocol(command: dict[str, Any]) -> dict[str, Any]:
"""Return the generated contract, including for legacy in-memory plans."""
protocol = command.get("protocol")
if isinstance(protocol, dict):
return protocol
message = str(command.get("message", ""))
if message == "T2G_Seat_Thermal_Mode_3CE":
rule_id, key = "can0.seat.thermal", "thermal_mode"
elif message == "T2G_Seat_Thermal_Control_29E":
rule_id, key = "can0.seat.thermal", "thermal_control"
elif message == "T2G_Seat_Move_Command_300":
opcode = int(command.get("signals", {}).get("Opcode", -1))
if opcode in {0x0E, 0x0F}:
rule_id, key = "can0.seat.dynamic", "dynamic"
elif opcode == 0x03:
rule_id, key = "can0.seat.relative", "move"
else:
rule_id, key = "can0.seat.position", "move"
else:
raise HardwareExecutionError(
f"cannot resolve generated CAN0 protocol for message {message}"
)
return shared_protocol_contract(rule_id, key)
@classmethod
def _protocol_feedback_ids(cls, command: dict[str, Any]) -> tuple[int, ...]:
protocol = cls._resolved_protocol(command)
values = protocol.get("feedback_ids")
if not isinstance(values, list) or not values:
raise HardwareExecutionError(
"compiled CAN0 command has no generated feedback IDs"
)
return tuple(int(str(value), 16) for value in values)
def _receive_until(
self,
@@ -608,7 +1058,13 @@ class HardwareSeatExecutor:
}
def _wait_ack(
self, bus: CanBus, sequence: int, opcode: int, *, expected_ack: int = 1
self,
bus: CanBus,
sequence: int,
opcode: int,
*,
expected_ack: int = 1,
arbitration_id: int = 0x301,
) -> tuple[dict[str, int], CanFrame]:
def matches(values: dict[str, int], _: CanFrame) -> bool:
if values["Last_Sequence"] != sequence or values["Last_Opcode"] != opcode:
@@ -628,7 +1084,9 @@ class HardwareSeatExecutor:
)
return True
return self._receive_until(bus, 0x301, self.ack_timeout_ms, matches)
return self._receive_until(
bus, arbitration_id, self.ack_timeout_ms, matches
)
def _stop_all(self, bus: CanBus, *, require_ack: bool) -> tuple[CanFrame, CanFrame | None]:
sequence = self._next_sequence()
@@ -659,7 +1117,14 @@ class HardwareSeatExecutor:
command: dict[str, Any],
operations: list[dict[str, Any]],
) -> list[dict[str, Any]]:
command_frame = self._send_encoded(bus, command["message"], command["signals"])
command_frame = self._send_encoded(
bus,
command["message"],
command["signals"],
self._resolved_protocol(command),
)
self._active_command_frame = command_frame
(thermal_feedback_id,) = self._protocol_feedback_ids(command)
expected: dict[str, int] = {}
for operation in operations:
feature = operation["capability"].removeprefix("seat.set_")
@@ -676,7 +1141,7 @@ class HardwareSeatExecutor:
return all(values[name] == value for name, value in expected.items())
values, feedback = self._receive_until(
bus, 0x4FA, self.thermal_timeout_ms, matches
bus, thermal_feedback_id, self.thermal_timeout_ms, matches
)
output = []
for operation in operations:
@@ -731,9 +1196,16 @@ class HardwareSeatExecutor:
signals = dict(command["signals"])
sequence = self._next_sequence()
signals["Sequence"] = sequence
command_frame = self._send_encoded(bus, command["message"], signals)
ack_feedback_id, dynamic_feedback_id = self._protocol_feedback_ids(command)
command_frame = self._send_encoded(
bus, command["message"], signals, self._resolved_protocol(command)
)
self._active_command_frame = command_frame
ack_values, ack_frame = self._wait_ack(
bus, sequence, 0x0F if stopping else 0x0E
bus,
sequence,
0x0F if stopping else 0x0E,
arbitration_id=ack_feedback_id,
)
completion = command["completion"]
@@ -753,7 +1225,7 @@ class HardwareSeatExecutor:
status_values, status_frame = self._receive_until(
bus,
0x306,
dynamic_feedback_id,
int(completion["timeout_ms"]),
matches,
)
@@ -889,8 +1361,14 @@ class HardwareSeatExecutor:
sequence = self._next_sequence()
signals["Sequence"] = sequence
self._reset_current_samples(motor)
command_frame = self._send_encoded(bus, command["message"], signals)
ack_values, ack_frame = self._wait_ack(bus, sequence, opcode)
ack_feedback_id, motor_feedback_id = self._protocol_feedback_ids(command)
command_frame = self._send_encoded(
bus, command["message"], signals, self._resolved_protocol(command)
)
self._active_command_frame = command_frame
ack_values, ack_frame = self._wait_ack(
bus, sequence, opcode, arbitration_id=ack_feedback_id
)
def complete(values: dict[str, int], _: CanFrame) -> bool:
if values["Motor_Sub_ID"] != motor or values["Motor_Sequence"] != sequence:
@@ -914,7 +1392,7 @@ class HardwareSeatExecutor:
values, feedback = self._receive_until(
bus,
0x302,
motor_feedback_id,
int(command["completion"]["timeout_ms"]),
complete,
)
@@ -1362,8 +1840,14 @@ class HardwareSeatExecutor:
signals["Sequence"] = sequence
self._reset_current_samples(motor)
motion_started = time.perf_counter()
command_frame = self._send_encoded(bus, command["message"], signals)
ack_values, ack_frame = self._wait_ack(bus, sequence, 3)
ack_feedback_id, motor_feedback_id = self._protocol_feedback_ids(command)
command_frame = self._send_encoded(
bus, command["message"], signals, self._resolved_protocol(command)
)
self._active_command_frame = command_frame
ack_values, ack_frame = self._wait_ack(
bus, sequence, 3, arbitration_id=ack_feedback_id
)
def terminal(values: dict[str, int], _: CanFrame) -> bool:
if values["Motor_Sub_ID"] != motor or values["Motor_Sequence"] != sequence:
@@ -1374,7 +1858,7 @@ class HardwareSeatExecutor:
values, feedback = self._receive_until(
bus,
0x302,
motor_feedback_id,
int(command["completion"]["timeout_ms"]),
terminal,
)
+497 -64
View File
@@ -6,12 +6,13 @@ import math
import re
import unicodedata
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
TARGET_PATTERN = re.compile(
r"主副驾|前排右侧|右前座椅|右前座|副驾驶座椅|副驾驶座|副驾驶|副驾位|"
r"乘客座椅|乘客座|乘客位|主驾驶座椅|主驾驶座|主驾驶|驾驶位|驾驶座|"
r"乘客座椅|乘客座|乘客位|乘员座椅|乘员座|乘员位|乘员|"
r"主驾驶座椅|主驾驶座|主驾驶|驾驶位|驾驶员|驾驶座|"
r"司机位|司机座|主驾|前排|副驾"
)
FEATURE_PATTERN = re.compile(r"通风|加热")
@@ -20,28 +21,55 @@ STATE_PATTERN = re.compile(
)
LEVEL_PATTERN = re.compile(r"(最高|最低|[1-5一二三四五高中低])\s*档")
POSITION_PATTERN_TEMPLATE = (
r"{alias}(?:位置)?(?:调到|移动到|移到|设为|设置为|调至|移动至|到|为)?"
r"{alias}(?:位置)?(?:调到|调至|移动到|移动至|移到|移至|"
r"转动到|转动至|旋转到|旋转至|调节到|调节至|调整到|调整至|"
r"设为|设置为|设置到|设置至|设置|设|到|为)?"
r"(?P<position>[+-]?\d{{1,3}}(?:\.\d+)?)%"
)
ANGLE_PATTERN_TEMPLATE = (
r"{alias}(?:夹角|角度)?(?:调到|放倒到|抬到|移动到|移到|设为|设置为|"
r"调至|到|为)?(?P<angle>\d{{2,3}})(?:度|°)"
r"{alias}(?:夹角|角度)?(?:调到|调至|放倒到|抬到|移动到|移动至|移到|移至|"
r"转动到|转动至|旋转到|旋转至|调节到|调节至|调整到|调整至|"
r"设为|设置为|设置到|设置至|设置|设|到|为)?"
r"(?P<angle>\d{{2,3}})(?:度|°)"
)
RELATIVE_ANGLE_PATTERN_TEMPLATE = (
r"{alias}(?:再|继续|接着|然后)?(?P<direction>向前|往前|朝前|前倾|向后|往后|朝后|后仰|"
r"{alias}(?:再|继续|接着|然后)?"
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
r"(?:再|继续|接着|然后)?"
r"(?P<direction>向前|往前|朝前|前倾|向后|往后|朝后|后仰|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})(?:度|°)"
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
r"(?P<delta>\d{{1,3}})(?:度|°)"
)
RELATIVE_PERCENT_PATTERN_TEMPLATE = (
r"{alias}(?:再|继续|接着|然后)?(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
r"{alias}(?:再|继续|接着|然后)?"
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
r"(?:再|继续|接着|然后)?"
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})%"
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
r"(?P<delta>\d{{1,3}})%"
)
DIRECTIONAL_ABSOLUTE_PERCENT_PATTERN = re.compile(
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)*"
r"(?:到|至|为)"
r"(?P<position>[+-]?\d{1,3}(?:\.\d+)?)%"
)
DIRECTIONAL_RELATIVE_PERCENT_PATTERN = re.compile(
r"(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
r"(?:移动|移|转动|旋转|转|调节|调整|调|设置|设|倾斜|抬|升|降)?"
r"(?P<delta>\d{1,3})%"
)
TIMED_MOTION_PATTERN_TEMPLATE = (
r"{alias}(?:持续|保持)?(?:再|继续|接着|然后)?"
r"(?:移动|移|转动|旋转|转|调节|调整|调|倾斜|抬|升|降)?"
r"(?:再|继续|接着|然后)?"
r"(?P<direction>向前|往前|朝前|前移|前倾|向后|往后|朝后|后移|后仰|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下)"
r"(?:移动|移|调节|调整|调|倾斜|抬|升|降|运行|运动|持续|保持|时长)*"
r"(?:移动|移|转动|旋转|转|调节|调整|调|倾斜|抬|升|降|运行|运动|持续|保持|时长)*"
r"(?P<duration>\d+(?:\.\d+)?)(?P<unit>毫秒|ms|秒|s|分钟|分|min)"
)
ZERO_POSITION_PATTERN = re.compile(r"归零|回零|复位|零位")
@@ -52,9 +80,19 @@ STRONG_PATTERN = re.compile(r"很|太|非常|特别|难受")
AIRFLOW_CAUSE_PATTERN = re.compile(r"吹|风太大|风吹")
FIRST_PERSON_PATTERN = re.compile(r"我|自己")
VEHICLE_SCOPE_PATTERN = re.compile(r"车上|车内|车里|全车|整车|整个车")
NON_SEAT_DEVICE_PATTERN = re.compile(
r"车窗|(?:左前|前左|右前|前右|左后|后左|右后|后右)(?:排)?窗|"
r"车门|门锁|空调|温度|风量|风速|出风模式|"
r"内循环|外循环|循环模式|风口|遮阳帘|天幕|阅读灯|"
r"室内灯|车内灯|氛围灯"
)
SEAT_CONTROL_PATTERN = re.compile(
r"座椅|坐椅|座位|靠背|椅背|坐盆|坐垫|滑轨|腿托|脚托|四轴|"
r"通风|加热|按摩|记忆|预设"
)
PASSENGER_ONLY_QUESTION = "当前所有座椅能力仅控制副驾,不支持主驾座椅目标。"
UNSUPPORTED_SEAT_SCOPE_PATTERN = re.compile(
r"后排|第二排|二排|第三排|三排|左前座椅|左前座|左前|"
r"后排|后座|中排|第二排|二排|第三排|三排|左前座椅|左前座|左前|"
r"右后座椅|右后座|右后|左后座椅|左后座|左后|"
r"所有座椅|全部座椅|全车座椅"
)
@@ -71,6 +109,7 @@ TARGETS = {
"主驾驶座椅": ["driver"],
"驾驶座": ["driver"],
"司机座": ["driver"],
"驾驶员": ["driver"],
"副驾": ["passenger"],
"副驾驶": ["passenger"],
"副驾驶座": ["passenger"],
@@ -79,6 +118,10 @@ TARGETS = {
"乘客位": ["passenger"],
"乘客座": ["passenger"],
"乘客座椅": ["passenger"],
"乘员": ["passenger"],
"乘员位": ["passenger"],
"乘员座": ["passenger"],
"乘员座椅": ["passenger"],
"右前座": ["passenger"],
"右前座椅": ["passenger"],
"前排右侧": ["passenger"],
@@ -116,11 +159,51 @@ LEVELS = {
MOTION_AXIS_ALIASES = {
"longitudinal": ("水平", "前后", "纵向", "滑轨"),
# Internal capability name is retained for compatibility. On the
# seat-ota CAN0 firmware this board axis is the physical seat-height axis.
"cushion_tilt": ("座椅高度", "高度", "坐垫倾角", "坐盆", "坐垫"),
# Native CAN0 firmware axis 1 is the physical seat-height axis.
"cushion_tilt": (
"座椅高度",
"高度",
"坐垫倾角",
"坐盆倾角",
"坐盆",
"坐垫",
),
"backrest_recline": ("靠背", "椅背"),
"legrest_height": ("腿部支撑", "小腿托", "腿托", "脚托"),
"legrest_height": (
"腿部支撑高度",
"小腿托高度",
"腿托高度",
"脚托高度",
"腿部支撑",
"小腿托",
"腿托",
"脚托",
),
}
def _axis_alias_present(axis: str, alias: str, text: str) -> bool:
"""Return whether an axis alias is unambiguous in the supplied text.
``高度`` by itself is a supported shorthand for the passenger seat-height
axis, but it also occurs inside explicit leg-rest phrases such as
``腿托高度``. Treating that suffix as a second cushion alias makes the
parser choose the cushion axis before it reaches the more-specific leg-rest
axis. The explicit component name must win.
"""
if axis == "cushion_tilt" and alias == "高度":
legrest_aliases = MOTION_AXIS_ALIASES["legrest_height"]
if any(f"{legrest_alias}高度" in text for legrest_alias in legrest_aliases):
return False
return alias in text
def _axis_present(axis: str, text: str) -> bool:
return any(
_axis_alias_present(axis, alias, text)
for alias in MOTION_AXIS_ALIASES[axis]
)
MOTION_ANGLE_RANGES = {
"backrest_recline": (95, 163),
"legrest_height": (95, 167),
@@ -140,8 +223,8 @@ RELATIVE_DIRECTION_SIGNS = {
"decrease": {"向上", "往上", "抬高", "抬起", "升高", "上"},
},
"backrest_recline": {
"increase": {"向后", "往后", "朝后", "后仰", "后"},
"decrease": {"向前", "往前", "朝前", "前倾", "前"},
"increase": {"向后", "往后", "朝后", "后移", "后仰", "后倾", "后"},
"decrease": {"向前", "往前", "朝前", "前移", "前倾", "前"},
},
"legrest_height": {
"increase": {"向上", "往上", "抬高", "抬起", "升高", "上"},
@@ -174,6 +257,85 @@ MOTION_ENDPOINT_LABELS = {
"legrest_height": {"increase": "最上方", "decrease": "最下方"},
}
# Seat motion endpoints all share the same language shape: a physical axis,
# an optional direction, a motion verb, and a terminal position. Keeping the
# endpoint vocabulary in one table prevents one axis (historically the
# backrest) from accepting natural phrases that another axis (the leg rest or
# seat height) silently rejects.
MOTION_PHYSICAL_ENDPOINTS: dict[str, dict[int, tuple[str, ...]]] = {
"longitudinal": {
0: ("最前面", "最前方", "最前位置", "最前"),
100: ("最后面", "最后方", "最后位置", "最后"),
},
"cushion_tilt": {
0: ("最上方", "最高位置", "最高"),
100: ("最下方", "最低位置", "最低"),
},
"backrest_recline": {
0: ("调直", "立直", "竖直", "最前方", "最前位置", "最前"),
100: (
"完全放倒",
"放倒到最后",
"最后方",
"最后位置",
"最后",
),
},
"legrest_height": {
0: ("收起", "收回", "最下方", "最低位置", "最低"),
100: ("完全抬起", "最上方", "最高位置", "最高"),
},
}
MOTION_DEFAULT_EXTREMA: dict[str, dict[str, int | None]] = {
# "最大位置" has no physical meaning for a bidirectional rail unless the
# user also gives a direction. The remaining axes have stable product
# semantics for maximum/minimum posture.
"longitudinal": {"最大": None, "最小": None},
"cushion_tilt": {"最大": 0, "最小": 100},
"backrest_recline": {"最大": 100, "最小": 0},
"legrest_height": {"最大": 100, "最小": 0},
}
MOTION_LEGACY_DIRECTIONAL_ENDPOINTS: dict[str, dict[int, tuple[str, ...]]] = {
"longitudinal": {
0: ("向前移到底", "推到最前"),
100: ("向后移到底", "移到最后"),
},
"cushion_tilt": {
0: ("抬到头", "抬到底"),
100: ("降到头", "降到底"),
},
"backrest_recline": {
0: ("前倾到底",),
100: ("放倒到底", "后仰到底", "后移到底"),
},
"legrest_height": {
0: ("降到头", "降到底"),
100: ("升到头", "升到底", "抬到底"),
},
}
MOTION_ENDPOINT_DIRECTION_PATTERN = (
r"向前|往前|朝前|前移|前倾|向后|往后|朝后|后移|后仰|后倾|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下|前|后|上|下"
)
MOTION_ENDPOINT_FILLER_PATTERN = (
r"(?:再|继续|接着|然后|移动|移|转动|旋转|转|调节|调整|调|"
r"设置|设|倾斜|抬|升|降|推|拉)*"
)
MOTION_GENERIC_TERMINAL_PATTERN = (
r"(?:最大|最小|极限)(?:位置|角度|行程)?|(?:到|至)(?:底|头|尽头|末端)"
)
@dataclass(frozen=True)
class SeatEndpointResolution:
"""Result of resolving one absolute endpoint phrase for one seat axis."""
matched: bool
position_pct: int | None = None
question: str | None = None
direction: str | None = None
endpoint: str | None = None
def _chinese_integer(token: str) -> int | None:
if token.isdigit():
@@ -213,6 +375,7 @@ def _normalize_text(value: str) -> str:
text = re.sub(r"座椅(?:吹风|送风)", "座椅通风", text)
text = re.sub(r"座椅(?:取暖|加温)", "座椅加热", text)
text = text.replace("座椅暖一下", "座椅加热打开")
text = text.replace("推托", "腿托")
def replace_percent(match: re.Match[str]) -> str:
number = _chinese_integer(match.group(1))
@@ -242,6 +405,198 @@ def _relative_direction(axis: str, token: str) -> str | None:
return None
def _endpoint_position(axis: str, endpoint: str) -> int | None:
for position_pct, tokens in MOTION_PHYSICAL_ENDPOINTS[axis].items():
if endpoint in tokens:
return position_pct
for position_pct, tokens in MOTION_LEGACY_DIRECTIONAL_ENDPOINTS[axis].items():
if endpoint in tokens:
return position_pct
return None
def resolve_seat_axis_endpoint(
text: str,
axis: str,
*,
aliases: tuple[str, ...] | None = None,
) -> SeatEndpointResolution:
"""Resolve compositional absolute endpoint language for one seat axis.
Directional terminal phrases use the physical polarity already shared by
relative and timed motion. Named physical endpoints are checked against
an explicit direction so contradictory requests fail closed instead of
silently choosing one half of the sentence.
"""
if axis not in MOTION_AXIS_ALIASES:
raise ValueError(f"unknown seat motion axis: {axis}")
normalized = _normalize_text(text)
axis_aliases = aliases or MOTION_AXIS_ALIASES[axis]
alias_pattern = "(?:" + "|".join(
re.escape(alias) for alias in sorted(set(axis_aliases), key=len, reverse=True)
) + ")"
named_endpoints = {
token
for tokens in MOTION_PHYSICAL_ENDPOINTS[axis].values()
for token in tokens
}
named_endpoints.update(
token
for tokens in MOTION_LEGACY_DIRECTIONAL_ENDPOINTS[axis].values()
for token in tokens
)
endpoint_pattern = "(?:" + "|".join(
[
*(re.escape(token) for token in sorted(named_endpoints, key=len, reverse=True)),
MOTION_GENERIC_TERMINAL_PATTERN,
]
) + ")"
directional = re.search(
rf"{alias_pattern}(?:位置|角度)?{MOTION_ENDPOINT_FILLER_PATTERN}"
rf"(?P<direction>{MOTION_ENDPOINT_DIRECTION_PATTERN})"
rf"{MOTION_ENDPOINT_FILLER_PATTERN}(?:到|至|为|成)?"
rf"(?P<endpoint>{endpoint_pattern})(?![+-]?\d)",
normalized,
)
if directional is not None:
direction = directional.group("direction")
endpoint = directional.group("endpoint")
operation = _relative_direction(axis, direction)
if operation is None:
allowed = "或".join(MOTION_ENDPOINT_LABELS[axis].values())
return SeatEndpointResolution(
matched=True,
question=(
f"{MOTION_AXIS_LABELS[axis]}不支持“{direction}”方向;"
f"请改用与该部件一致的方向,或直接指定{allowed}。"
),
direction=direction,
endpoint=endpoint,
)
direction_position = 100 if operation == "increase" else 0
named_position = _endpoint_position(axis, endpoint)
if named_position is not None and named_position != direction_position:
return SeatEndpointResolution(
matched=True,
question=(
f"{MOTION_AXIS_LABELS[axis]}的方向“{direction}”与端点"
f"“{endpoint}”相冲突;请只保留一个一致的方向和目标位置。"
),
direction=direction,
endpoint=endpoint,
)
return SeatEndpointResolution(
matched=True,
position_pct=direction_position,
direction=direction,
endpoint=endpoint,
)
direct = re.search(
rf"{alias_pattern}(?:位置|角度)?{MOTION_ENDPOINT_FILLER_PATTERN}"
rf"(?:到|至|为|成)?(?P<endpoint>{endpoint_pattern})(?![+-]?\d)",
normalized,
)
if direct is None:
return SeatEndpointResolution(matched=False)
endpoint = direct.group("endpoint")
position_pct = _endpoint_position(axis, endpoint)
if position_pct is not None:
return SeatEndpointResolution(
matched=True,
position_pct=position_pct,
endpoint=endpoint,
)
generic = re.match(r"(?P<extent>最大|最小)", endpoint)
if generic is not None:
position_pct = MOTION_DEFAULT_EXTREMA[axis][generic.group("extent")]
if position_pct is not None:
return SeatEndpointResolution(
matched=True,
position_pct=position_pct,
endpoint=endpoint,
)
return SeatEndpointResolution(
matched=True,
question=(
f"{MOTION_AXIS_LABELS[axis]}的“{endpoint}”缺少明确方向;"
f"请说明移动到{MOTION_ENDPOINT_LABELS[axis]['decrease']}还是"
f"{MOTION_ENDPOINT_LABELS[axis]['increase']},也可以直接指定0到100%的位置。"
),
endpoint=endpoint,
)
def _directional_motion_axis(clause: str, direction: str) -> str | None:
"""Resolve an omitted passenger-seat axis from the movement direction."""
explicit_axes = [
axis
for axis in MOTION_AXIS_ALIASES
if _axis_present(axis, clause)
]
if len(explicit_axes) == 1:
return explicit_axes[0]
if explicit_axes:
return None
if TARGET_PATTERN.search(clause) is None and not re.search(r"座椅|座位", clause):
return None
if direction in {
"向前",
"往前",
"朝前",
"前移",
"向后",
"往后",
"朝后",
"后移",
}:
return "longitudinal"
if direction in {
"向上",
"往上",
"抬高",
"抬起",
"升高",
"向下",
"往下",
"降低",
"降下",
}:
return "cushion_tilt"
return None
def _absolute_percent_motion_action(
axis: str, requested_position_value: float
) -> dict[str, Any]:
requested_position_pct = _round_half_away_from_zero(requested_position_value)
position_pct = max(0, min(100, requested_position_pct))
action: dict[str, Any] = {
"target": "passenger",
"action_type": "position",
"axis": axis,
"position_pct": position_pct,
}
notices: list[str] = []
if requested_position_value != requested_position_pct:
notices.append(
f"{MOTION_AXIS_LABELS[axis]}请求位置"
f"{_format_numeric_request(requested_position_value)}%;"
f"已取整为{requested_position_pct}%。"
)
if requested_position_pct != position_pct:
notices.append(
f"取整后超过0到100%行程;已采用{position_pct}%的备用目标"
"并移动到对应机械末端。"
)
if notices:
action["user_notice"] = " ".join(notices)
return action
def _context_motion_state(
context: dict[str, Any] | None, axis: str
) -> dict[str, Any] | None:
@@ -799,6 +1154,16 @@ def parse_local_intent(
"""Parse common Chinese seat commands without making hardware assumptions."""
text = _normalize_text(utterance)
if NON_SEAT_DEVICE_PATTERN.search(text) and not SEAT_CONTROL_PATTERN.search(text):
# This parser owns CAN0 passenger-seat semantics only. Occupant words
# such as “主驾/副驾” in a window or climate clause are spatial
# qualifiers for that device and must not create a seat ambiguity.
return {
"actions": [],
"ambiguities": [],
"decision_summary": [],
"confidence": 0.0,
}
clauses = [part for part in re.split(r"[,,。;;!!??]", text) if part]
actions: list[dict[str, Any]] = []
motion_actions: list[dict[str, Any]] = []
@@ -822,8 +1187,8 @@ def parse_local_intent(
mentioned_axes = [
axis
for axis, aliases in MOTION_AXIS_ALIASES.items()
if any(alias in text for alias in aliases)
for axis in MOTION_AXIS_ALIASES
if _axis_present(axis, text)
]
if ZERO_POSITION_PATTERN.search(text) and (mentioned_axes or "四轴" in text):
reset_axes = list(MOTION_AXIS_ALIASES) if "四轴" in text else mentioned_axes
@@ -905,8 +1270,8 @@ def parse_local_intent(
clause_axes = [
axis
for axis, aliases in MOTION_AXIS_ALIASES.items()
if any(alias in clause for alias in aliases)
for axis in MOTION_AXIS_ALIASES
if _axis_present(axis, clause)
]
percentages = list(
re.finditer(r"(?P<position>[+-]?\d{1,3}(?:\.\d+)?)%", clause)
@@ -963,6 +1328,32 @@ def parse_local_intent(
if shared_position is not None:
continue
directional_absolute_axes: set[str] = set()
directional_absolute_match = DIRECTIONAL_ABSOLUTE_PERCENT_PATTERN.search(clause)
if directional_absolute_match is not None:
axis = _directional_motion_axis(
clause, directional_absolute_match.group("direction")
)
if axis is not None:
directional_absolute_axes.add(axis)
target_zh = (
_nearest_preceding_target(
clause, directional_absolute_match.start()
)
or "副驾"
)
if "driver" in TARGETS[target_zh]:
ambiguities.append(
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
)
else:
motion_actions.append(
_absolute_percent_motion_action(
axis,
float(directional_absolute_match.group("position")),
)
)
timed_axes: set[str] = set()
timed_aliases = {
**MOTION_AXIS_ALIASES,
@@ -970,6 +1361,8 @@ def parse_local_intent(
}
for axis, aliases in timed_aliases.items():
for alias in aliases:
if not _axis_alias_present(axis, alias, clause):
continue
match = re.search(
TIMED_MOTION_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
clause,
@@ -1003,9 +1396,11 @@ def parse_local_intent(
relative_axes: set[str] = set()
for axis, aliases in MOTION_AXIS_ALIASES.items():
if axis in timed_axes:
if axis in timed_axes or axis in directional_absolute_axes:
continue
for alias in aliases:
if not _axis_alias_present(axis, alias, clause):
continue
angle_match = re.search(
RELATIVE_ANGLE_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
clause,
@@ -1043,55 +1438,86 @@ def parse_local_intent(
motion_actions.append(action)
break
directional_relative_match = DIRECTIONAL_RELATIVE_PERCENT_PATTERN.search(clause)
if (
directional_relative_match is not None
and not relative_axes
and not directional_absolute_axes
):
axis = _directional_motion_axis(
clause, directional_relative_match.group("direction")
)
if axis is not None and axis not in timed_axes:
relative_axes.add(axis)
target_zh = (
_nearest_preceding_target(
clause, directional_relative_match.start()
)
or "副驾"
)
if "driver" in TARGETS[target_zh]:
ambiguities.append(
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
)
else:
action, question = _relative_motion_action(
axis,
directional_relative_match.group("direction"),
int(directional_relative_match.group("delta")),
"percent",
context,
)
if action is None:
ambiguities.append(
{
"field": f"passenger.position.{axis}",
"question": question
or "无法计算座椅相对移动目标。",
}
)
else:
motion_actions.append(action)
endpoint_actions: dict[str, int] = {}
endpoint_axes: set[str] = set()
has_specific_non_longitudinal_axis = any(
any(alias in clause for alias in MOTION_AXIS_ALIASES[axis])
_axis_present(axis, clause)
for axis in ("cushion_tilt", "backrest_recline", "legrest_height")
)
has_longitudinal_axis = any(
alias in clause for alias in MOTION_AXIS_ALIASES["longitudinal"]
) or (
has_longitudinal_axis = _axis_present("longitudinal", clause) or (
not has_specific_non_longitudinal_axis
and any(alias in clause for alias in ("座椅", "座位"))
)
if has_longitudinal_axis:
if re.search(r"最前面|最前|向前移到底|推到最前", clause):
endpoint_actions["longitudinal"] = 0
elif re.search(r"最后面|最后|向后移到底|移到最后", clause):
endpoint_actions["longitudinal"] = 100
cushion_alias = r"(?:座椅高度|高度|坐垫倾角|坐盆|坐垫)"
if re.search(
cushion_alias + r"(?:抬到最上方|调到最上方|移到最上方|最高|抬到头|抬到底)",
clause,
):
endpoint_actions["cushion_tilt"] = 0
elif re.search(
cushion_alias + r"(?:降到最下方|调到最下方|移到最下方|最低|降到头|降到底)",
clause,
):
endpoint_actions["cushion_tilt"] = 100
backrest_alias = r"(?:靠背|椅背)"
if re.search(backrest_alias + r"(?:调直|立直|竖直)", clause):
endpoint_actions["backrest_recline"] = 0
elif re.search(
backrest_alias
+ r"(?:放倒到最后|放倒到底|完全放倒|后仰到底|向后移到底|最大角度)",
clause,
):
endpoint_actions["backrest_recline"] = 100
legrest_alias = r"(?:腿部支撑|小腿托|腿托|脚托)"
if re.search(
legrest_alias
+ r"(?:收起|收回|降到最下方|调到最下方|降到最低|最低|降到头|降到底)",
clause,
):
endpoint_actions["legrest_height"] = 0
elif re.search(
legrest_alias
+ r"(?:升到最上方|抬到最上方|调到最上方|升到最高|抬到最高|最高|完全抬起|升到头|升到底|抬到底)",
clause,
):
endpoint_actions["legrest_height"] = 100
endpoint_aliases = dict(MOTION_AXIS_ALIASES)
if has_longitudinal_axis and not _axis_present("longitudinal", clause):
endpoint_aliases["longitudinal"] = (
*MOTION_AXIS_ALIASES["longitudinal"],
"座椅",
"座位",
)
for axis, aliases in endpoint_aliases.items():
if axis == "longitudinal" and not has_longitudinal_axis:
continue
if axis != "longitudinal" and not _axis_present(axis, clause):
continue
resolution = resolve_seat_axis_endpoint(
clause,
axis,
aliases=aliases,
)
if not resolution.matched:
continue
endpoint_axes.add(axis)
if resolution.question:
ambiguities.append(
{
"field": f"passenger.position.{axis}",
"question": resolution.question,
}
)
continue
if resolution.position_pct is not None:
endpoint_actions[axis] = resolution.position_pct
for axis, position_pct in endpoint_actions.items():
target_zh = _nearest_preceding_target(clause, len(clause)) or "副驾"
if "driver" in TARGETS[target_zh]:
@@ -1109,9 +1535,16 @@ def parse_local_intent(
)
for axis, aliases in MOTION_AXIS_ALIASES.items():
if axis in endpoint_actions or axis in relative_axes or axis in timed_axes:
if (
axis in endpoint_axes
or axis in relative_axes
or axis in timed_axes
or axis in directional_absolute_axes
):
continue
for alias in aliases:
if not _axis_alias_present(axis, alias, clause):
continue
position_match = re.search(
POSITION_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
clause,
+14 -1
View File
@@ -40,7 +40,20 @@ def validate_semantics(
for index, deferred in enumerate(parsed.get("deferred_requests", [])):
domain = str(deferred.get("domain"))
capability = str(deferred.get("capability"))
if domain not in detected_domains or not capability.startswith(f"{domain}."):
# An N7-synchronised scene may use a vehicle-owned capability namespace
# that intentionally differs from its routing domain (for example
# ``domain=local_vehicle`` with ``capability=local_scene.seat_preset``).
# It is not executed by this validator's local backend: the original
# action is forwarded unchanged to ``transto_n7/ctl``. Keep the strict
# namespace check for every ordinary deferred request, while allowing
# this explicitly marked transfer backend to retain the source contract.
n7_transfer = (
deferred.get("execution_backend") == "n7_vehicle_transfer"
and deferred.get("rejection_code") == "PC_CAPABILITY_UNAVAILABLE"
)
if domain not in detected_domains or (
not n7_transfer and not capability.startswith(f"{domain}.")
):
issues.append(
{
"severity": "error",
@@ -1,5 +1,6 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
@@ -11,6 +12,16 @@ from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentErro
class SeatServiceIntegrationTests(unittest.TestCase):
def setUp(self) -> None:
self.memory_environment = patch.dict(
os.environ,
{
"COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH": str(
REPOSITORY_ROOT / "tests" / "fixtures" / "driver_seat_memory.json"
)
},
)
self.memory_environment.start()
self.addCleanup(self.memory_environment.stop)
self.service = SeatDemoService(REPOSITORY_ROOT)
self.temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary_directory.cleanup)
@@ -32,6 +43,26 @@ class SeatServiceIntegrationTests(unittest.TestCase):
for event in result["trace_events"]:
self.assertEqual(self.service.contracts.validate(event), [])
def test_explicit_legrest_height_does_not_execute_the_seat_height_axis(self) -> None:
result = self.service.preview_native_can(
"副驾腿托高度调到50%",
provider="local",
execution_mode="dry_run",
conversation_id="conv-legrest-height-regression",
)
self.assertEqual(result["status"], "succeeded")
actions = result["intent_ir"]["spec"]["actions"]
self.assertEqual(len(actions), 1)
self.assertEqual(actions[0]["capability"], "seat.set_legrest_height")
self.assertEqual(actions[0]["parameters"], {"position_pct": 50})
self.assertEqual(
result["actuator_state"]["passenger"]["position"]["legrest_height"][
"position_pct"
],
50,
)
def test_dynamic_mode_runs_until_explicit_natural_language_stop(self) -> None:
conversation_id = "conv-dynamic-stop"
started = self.service.process(
@@ -274,8 +305,20 @@ class SeatServiceIntegrationTests(unittest.TestCase):
)
interpret.assert_called_once_with(
"播放音乐", context, self.service.capability_context
"播放音乐",
{
**context,
"natural_language_routing": {
"original_utterance": "空调温度设置为25度,播放音乐",
"parsed_clauses": ["空调温度设置为25度"],
"resolved_clauses": ["空调温度设置为25度"],
"unresolved_clauses": ["播放音乐"],
"cloud_input_utterance": "播放音乐",
},
},
self.service.capability_context,
)
self.assertNotIn("natural_language_routing", context)
self.assertEqual(
provider, f"local_rules+openai:{self.service.openai.model}"
)
@@ -311,7 +354,7 @@ class SeatServiceIntegrationTests(unittest.TestCase):
]
self.assertEqual(
commands,
["seat driver memory recall 3", "ac temp driver 25", "ac fan 1"],
["seat driver position 3", "ac temp driver 25", "ac fan 1"],
)
def test_model_climate_request_completes_domain_loop(self) -> None:
+221
View File
@@ -67,6 +67,24 @@ class LocalIntentParserTests(unittest.TestCase):
self.assertEqual(result["actions"][0]["target"], "passenger")
self.assertEqual(result["ambiguities"], [])
def test_non_seat_spatial_qualifiers_do_not_enter_can0_seat_rules(self) -> None:
utterances = (
"打开主驾车窗",
"打开副驾车窗",
"把主驾和副驾温度都设为24度",
"风量调到2档",
"设置主驾出风模式为吹脸",
"把副驾车窗移动到30%",
"将主驾空调温度设置为24度",
"把遮阳帘转动到20%",
)
for utterance in utterances:
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance)
self.assertEqual(result["actions"], [])
self.assertEqual(result["ambiguities"], [])
def test_parses_four_axis_zero_for_passenger(self) -> None:
result = parse_local_intent("把水平、坐盆、靠背、腿托四轴归零")
@@ -144,6 +162,148 @@ class LocalIntentParserTests(unittest.TestCase):
self.assertEqual(result["actions"][0]["position_pct"], 65)
self.assertEqual(result["actions"][0]["motion_intent"]["direction"], "decrease")
def test_parses_passenger_directional_relative_percentages(self) -> None:
context = self.motion_context()
for state in context["devices"]["passenger"]["position"].values():
state["position_pct"] = 50
cases = (
("副驾座椅往上移动10%", "cushion_tilt", "decrease", 10),
("副驾座椅往上移动20%", "cushion_tilt", "decrease", 20),
("副驾往下移动10%", "cushion_tilt", "increase", 10),
("副驾往下移动20%", "cushion_tilt", "increase", 20),
("副驾座椅往前移动10%", "longitudinal", "decrease", 10),
("副驾座椅往前移动20%", "longitudinal", "decrease", 20),
("副驾往后移动10%", "longitudinal", "increase", 10),
("副驾往后移动20%", "longitudinal", "increase", 20),
("副驾座椅靠背往前移动10%", "backrest_recline", "decrease", 10),
("副驾座椅靠背往前移动30%", "backrest_recline", "decrease", 30),
("副驾靠背往后移动10%", "backrest_recline", "increase", 10),
("副驾靠背往后移动30%", "backrest_recline", "increase", 30),
("副驾推托往上移动20%", "legrest_height", "increase", 20),
("副驾推托往上移动40%", "legrest_height", "increase", 40),
("副驾推托往下移动20%", "legrest_height", "decrease", 20),
("副驾推托往下移动40%", "legrest_height", "decrease", 40),
)
for utterance, axis, direction, delta in cases:
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance, context)
self.assertEqual(result["ambiguities"], [])
self.assertEqual(len(result["actions"]), 1)
action = result["actions"][0]
self.assertEqual(action["axis"], axis)
self.assertEqual(action["motion_intent"]["mode"], "relative")
self.assertEqual(action["motion_intent"]["direction"], direction)
self.assertEqual(action["motion_intent"]["delta_position_pct"], delta)
def test_motion_verbs_are_weak_signals_for_relative_seat_controls(self) -> None:
context = self.motion_context()
for state in context["devices"]["passenger"]["position"].values():
state["position_pct"] = 50
cases = (
("副驾座椅往前移动10%", "longitudinal", "decrease", 10),
("副驾靠背往后转动20%", "backrest_recline", "increase", 20),
("副驾腿托往上调节30%", "legrest_height", "increase", 30),
("副驾座椅高度往下设置10%", "cushion_tilt", "increase", 10),
("副驾靠背转动往前20%", "backrest_recline", "decrease", 20),
("副驾腿托设置往下30%", "legrest_height", "decrease", 30),
("副驾靠背往后旋转10度", "backrest_recline", "increase", 10),
)
for utterance, axis, direction, amount in cases:
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance, context)
self.assertEqual(result["ambiguities"], [])
self.assertEqual(len(result["actions"]), 1)
action = result["actions"][0]
self.assertEqual(action["axis"], axis)
self.assertEqual(action["motion_intent"]["mode"], "relative")
self.assertEqual(action["motion_intent"]["direction"], direction)
motion = action["motion_intent"]
requested = motion.get("requested_delta_angle_deg")
if requested is None:
requested = motion.get("requested_delta_position_pct")
if requested is None:
requested = motion.get(
"delta_angle_deg", motion.get("delta_position_pct")
)
self.assertEqual(requested, amount)
def test_directional_move_to_percentage_is_absolute_not_relative(self) -> None:
cases = (
("副驾座椅往上移动到10%", "cushion_tilt", 10),
("副驾座椅往上移动到20%", "cushion_tilt", 20),
("副驾往下移动到10%", "cushion_tilt", 10),
("副驾往下移动到20%", "cushion_tilt", 20),
("副驾座椅往前移动到10%", "longitudinal", 10),
("副驾座椅往前移动到20%", "longitudinal", 20),
("副驾往后移动到10%", "longitudinal", 10),
("副驾往后移动到20%", "longitudinal", 20),
("副驾座椅靠背往前移动到10%", "backrest_recline", 10),
("副驾座椅靠背往前移动到30%", "backrest_recline", 30),
("副驾靠背往后移动到10%", "backrest_recline", 10),
("副驾靠背往后移动到30%", "backrest_recline", 30),
("副驾推托往上移动到20%", "legrest_height", 20),
("副驾推托往上移动到40%", "legrest_height", 40),
("副驾推托往下移动到20%", "legrest_height", 20),
("副驾推托往下移动到40%", "legrest_height", 40),
)
for utterance, axis, position in cases:
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance, self.motion_context())
self.assertEqual(result["ambiguities"], [])
self.assertEqual(len(result["actions"]), 1)
action = result["actions"][0]
self.assertEqual(action["axis"], axis)
self.assertEqual(action["position_pct"], position)
self.assertNotIn("motion_intent", action)
def test_motion_verbs_with_position_markers_are_absolute(self) -> None:
cases = (
("副驾滑轨移动至60%", "longitudinal", 60),
("副驾靠背转动到30%", "backrest_recline", 30),
("副驾腿托调节至40%", "legrest_height", 40),
("副驾座椅高度设置为20%", "cushion_tilt", 20),
("副驾靠背往后转动到35%", "backrest_recline", 35),
("副驾腿托往上设置为45%", "legrest_height", 45),
("副驾靠背旋转到130度", "backrest_recline", 51),
)
for utterance, axis, position in cases:
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance, self.motion_context())
self.assertEqual(result["ambiguities"], [])
self.assertEqual(len(result["actions"]), 1)
action = result["actions"][0]
self.assertEqual(action["axis"], axis)
self.assertEqual(action["position_pct"], position)
self.assertNotIn("motion_intent", action)
def test_explicit_legrest_height_beats_generic_seat_height_alias(self) -> None:
for utterance in (
"副驾腿托高度调到50%",
"副驾小腿托高度设置为50%",
"脚托高度调至50%",
"腿部支撑高度设置到50%",
):
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance, self.motion_context())
self.assertEqual(result["ambiguities"], [])
self.assertEqual(len(result["actions"]), 1)
self.assertEqual(result["actions"][0]["axis"], "legrest_height")
self.assertEqual(result["actions"][0]["position_pct"], 50)
seat_height = parse_local_intent("副驾高度调到25%", self.motion_context())
self.assertEqual(len(seat_height["actions"]), 1)
self.assertEqual(seat_height["actions"][0]["axis"], "cushion_tilt")
self.assertEqual(seat_height["actions"][0]["position_pct"], 25)
def test_rejects_relative_motion_without_state_and_clamps_endpoint_overflow(self) -> None:
missing = parse_local_intent("靠背向前移动10度")
context = self.motion_context()
@@ -274,6 +434,16 @@ class LocalIntentParserTests(unittest.TestCase):
"坐盆抬到最上方": ("cushion_tilt", 0),
"坐盆降到最下方": ("cushion_tilt", 100),
"靠背放倒到最后": ("backrest_recline", 100),
"副驾座椅靠背向后移动到最大位置": ("backrest_recline", 100),
"靠背往后调整至极限位置": ("backrest_recline", 100),
"靠背调整到最大位置": ("backrest_recline", 100),
"副驾靠背往后移到底": ("backrest_recline", 100),
"副驾椅背后移到底": ("backrest_recline", 100),
"副驾靠背调到最后位置": ("backrest_recline", 100),
"靠背向前移动到最大位置": ("backrest_recline", 0),
"副驾靠背向前移到底": ("backrest_recline", 0),
"副驾椅背前倾到底": ("backrest_recline", 0),
"靠背设置为最小角度": ("backrest_recline", 0),
"腿托升到最高": ("legrest_height", 100),
"腿托降到最低": ("legrest_height", 0),
}
@@ -286,6 +456,57 @@ class LocalIntentParserTests(unittest.TestCase):
[expected],
)
def test_compositional_directional_endpoints_cover_every_seat_axis(self) -> None:
cases = {
"滑轨向前调整到最大位置": ("longitudinal", 0),
"前后往后移动至极限位置": ("longitudinal", 100),
"纵向朝前调节到极限位置": ("longitudinal", 0),
"水平向后设置到最大行程": ("longitudinal", 100),
"座椅高度向上调整到最大位置": ("cushion_tilt", 0),
"坐垫往下移动至极限位置": ("cushion_tilt", 100),
"坐盆倾角上调到最大位置": ("cushion_tilt", 0),
"坐垫倾角向下设置到最大行程": ("cushion_tilt", 100),
"靠背向前调整到最大位置": ("backrest_recline", 0),
"椅背往后移动至极限位置": ("backrest_recline", 100),
"靠背前倾调到最大角度": ("backrest_recline", 0),
"椅背后倾设置到最大行程": ("backrest_recline", 100),
"腿托向上调整到最大位置": ("legrest_height", 100),
"脚托往下移动至极限位置": ("legrest_height", 0),
"小腿托上调到最大位置": ("legrest_height", 100),
"腿部支撑高度向下设置到最大行程": ("legrest_height", 0),
}
for utterance, expected in cases.items():
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance)
self.assertEqual(result["ambiguities"], [])
self.assertEqual(
[(item["axis"], item["position_pct"]) for item in result["actions"]],
[expected],
)
def test_endpoint_conflicts_and_directionless_extremes_fail_with_a_real_hint(
self,
) -> None:
for utterance in (
"滑轨向前移到最后方",
"座椅高度向上调到最低",
"靠背向后调到最前",
"腿托向上调到最低",
"靠背向上调整到最大位置",
"腿托调整到极限位置",
"滑轨调整到最大位置",
):
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance)
self.assertEqual(result["actions"], [])
questions = [
item["question"]
for item in result["ambiguities"]
if item["field"].startswith("passenger.position.")
]
self.assertEqual(len(questions), 1)
self.assertIn("请", questions[0])
def test_parses_normal_half_second_and_minimum_clamped_timed_motion(self) -> None:
normal = parse_local_intent("滑轨向后移动2秒", self.motion_context())
half_second = parse_local_intent("靠背向后移动半秒", self.motion_context())
+81 -4
View File
@@ -930,6 +930,52 @@ class T2GHardwareTests(unittest.TestCase):
def test_passive_snapshot_reads_all_passenger_axes_and_thermal_state(self) -> None:
bus = ReactiveFakeBus(self.matrix)
bus.received.extend(
[
bus._frame(
"T2G_Seat_Move_System_Status_301",
{
"Ack": 1,
"System_State": 2,
"Last_Sequence": 9,
"Last_Opcode": 1,
"Position_Valid_Mask": 0x0F,
"Firmware_Revision": 1,
},
),
bus._frame(
"T2G_Seat_Move_Diagnostic_303",
{
"Motor_Sub_ID": 0,
"Hall_Count": 120,
"Max_Hall_Count": 480,
"Hall_Speed_Half_Pps": 4,
},
),
bus._frame(
"T2G_Seat_Move_Current_Diagnostic_304",
{
"Motor_Sub_ID": 0,
"Current_Sample_Valid": 1,
"Current_Baseline_Valid": 1,
"Current_Raw": 240,
"Current_Baseline_Raw": 100,
"Stall_Current_Delta_Threshold_Raw": 500,
},
),
bus._frame(
"T2G_Seat_Thermal_Current_Diagnostic_305",
{
"Fan_Current_Sample_Valid": 1,
"Heater_Current_Sample_Valid": 1,
"Fan_Running": 1,
"Heater_Level": 2,
"Fan_Current_Raw": 300,
"Heater_Current_Raw": 450,
},
),
]
)
for motor, value_x100 in enumerate((1200, 3400, 5600, 7800)):
bus.received.append(
bus._frame(
@@ -982,6 +1028,26 @@ class T2GHardwareTests(unittest.TestCase):
self.assertEqual(physical["ventilation"]["state"], "off")
self.assertEqual(physical["heating"]["state"], "on")
self.assertEqual(physical["heating"]["level"], 2)
self.assertEqual(physical["channel"], 0)
self.assertEqual(
physical["protocol_profile"], "passenger-seat-native-can0-v1"
)
self.assertEqual(physical["system"]["position_valid_mask"], 0x0F)
self.assertEqual(
physical["diagnostics"]["motors"]["longitudinal"]["hall_count"],
120,
)
self.assertEqual(
physical["diagnostics"]["motors"]["longitudinal"]["current"]["raw"],
240,
)
self.assertEqual(
physical["diagnostics"]["thermal"]["heater_current_raw"], 450
)
self.assertEqual(
physical["observed_frame_ids"],
["0x301", "0x302", "0x303", "0x304", "0x305", "0x4FA"],
)
self.assertTrue(bus.closed)
def test_executor_sends_firmware_valid_stop_all_before_four_axis_zero(self) -> None:
@@ -1516,11 +1582,18 @@ class T2GHardwareTests(unittest.TestCase):
self.matrix, lambda: bus, ack_timeout_ms=20
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "failed")
self.assertEqual([item.data[0] for item in bus.sent], [9, 1, 9])
self.assertTrue(
report["spec"]["results"][-1]["telemetry"]["stop_all_attempted"]
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(
report["spec"]["verification_status"],
"submitted_unverified",
)
self.assertEqual([item.data[0] for item in bus.sent], [9, 1, 9])
telemetry = report["spec"]["results"][-1]["telemetry"]
self.assertTrue(telemetry["stop_all_attempted"])
self.assertEqual(telemetry["delivery_status"], "submitted")
self.assertEqual(telemetry["command_frame"]["frame_id"], "0x300")
self.assertEqual(telemetry["verification_status"], "submitted_unverified")
self.assertFalse(telemetry["physical_effect_confirmed"])
def test_executor_waits_for_real_thermal_status(self) -> None:
config = function_config(
@@ -1536,6 +1609,7 @@ class T2GHardwareTests(unittest.TestCase):
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["feedback_frame"]["frame_id"], "0x4FA")
self.assertEqual(telemetry["feedback_value"], 3)
self.assertEqual(telemetry["verification_status"], "verified_success")
def test_executor_preserves_can_adapter_open_failure(self) -> None:
config = function_config(
@@ -1553,6 +1627,9 @@ class T2GHardwareTests(unittest.TestCase):
self.assertEqual(report["spec"]["status"], "failed")
failure = report["spec"]["results"][-1]
self.assertEqual(failure["telemetry"]["error_type"], "CanGatewayError")
self.assertEqual(
failure["telemetry"]["verification_status"], "failed_or_blocked"
)
self.assertIn("ZCAN_OpenDevice failed", failure["message"])
self.assertFalse(failure["telemetry"]["stop_all_attempted"])

Some files were not shown because too many files have changed in this diff Show More