10 Commits
762 changed files with 202403 additions and 728 deletions
+15
View File
@@ -106,3 +106,18 @@ ARK_CONTENT_MODEL=
ARK_REQUEST_TIMEOUT=30s
EMBEDDING_PROVIDER=local
ARK_EMBEDDING_MODEL=
# 首次认证与绑定(默认关闭;详见docs/onboarding-implementation.md)
ONBOARDING_ENABLED=false
ONBOARDING_PAUSE_CLAIMS=false
ONBOARDING_ACTIVATION_ENABLED=false
ONBOARDING_SMS_LOGIN_ENABLED=false
ONBOARDING_ALLOW_LOCAL_HTTP=false
ONBOARDING_MASTER_KEY=
ONBOARDING_MASTER_KEY_FILE=
ONBOARDING_ENTRY_URL=
ONBOARDING_TRUSTED_PROXY_CIDRS=
ONBOARDING_SELF_SIGNUP_TENANT_IDS=
ONBOARDING_SMS_GATEWAY_URL=
ONBOARDING_SMS_GATEWAY_TOKEN=
ONBOARDING_SMS_GATEWAY_TOKEN_FILE=
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
- name: Infrastructure configuration scan
run: >-
docker run --rm -v "$PWD:/src" aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969
config --exit-code 1 --severity HIGH,CRITICAL /src
config --ignorefile /src/.trivyignore.yaml --exit-code 1 --severity HIGH,CRITICAL /src
- name: Image vulnerability scan
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 image --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL data-terminal-cloud-backend:test
+4
View File
@@ -22,3 +22,7 @@ web/dist/
test/e2e/screenshots/
.design-evidence/
device-simulator-windows/story-continuity-rtc/
/.tmp-*/
/firmware/**/build/
/firmware/**/managed_components/
/firmware/**/backups/
+3 -3
View File
@@ -4,8 +4,8 @@ title = "Data terminal cloud secret scanning"
useDefault = true
[allowlist]
description = "HTTP idempotency keys are replay identifiers, not credentials"
description = "Exact public HTTP idempotency fixtures are replay identifiers, not credentials"
condition = "AND"
regexTarget = "line"
regexes = ['''Idempotency-Key''']
paths = ['''(^|/)internal/httpapi/conversation_handlers_test\.go$''']
regexes = ['''(?:test-idempotency-key-000[12]|test-activate-idempotency-0001|turns-0123456789abcdef|3ca4e514-cbde-41fc-aa32-6f1ed460cb01)''']
paths = ['''(^|/)internal/httpapi/conversation_handlers_test\.go$''','''(^|/)docs/设备终端接口文档\.md$''']
+9
View File
@@ -0,0 +1,9 @@
misconfigurations:
- id: KSV-0109
paths:
- "deploy/kubernetes/base/configmap.yaml"
statement: >-
RTC_TOKEN_ISSUER and the *_TTL values are public runtime metadata, not
credentials. All RTC signing keys, provider credentials, and token key
material are mounted from Kubernetes Secrets or the production CSI
secret store.
+15
View File
@@ -1,9 +1,24 @@
# 变更记录
## 0.81.0-rc.onboarding1 — 2026-09-12
设备C01–C11按ONBOARDING-1.1文档对齐,移除辅助流程字段;专用HTTP校时返回TIME1签名包。新设备SDK执行注册,平台提供配置、30秒许可、结果保存和首通状态;未知结果、重启及跨绑定执行受保护。迁移至58,更新绑定与运维页面,保留旧设备接入。详见docs/onboarding-release-20260912.md。
本项目使用语义化版本号。正式生产放行仍以仓库内的发布候选证据和外部门禁为准;里程碑标签只表示代码、迁移、接口和本地质量门禁形成了可追溯基线。
## 未发布
- 新增首次认证、按键绑定与独立激活流程(迁移53–57、OpenAPI 0.78.0):生产登记/加密交付/放行、DEVREQ1认证、手机OIDC或短信登录、原子绑定与双端ACK、供应商任务与人工对账、首通验收、手机页和运维入口。新开关默认关闭;旧设备协议保留。详见[实施与联调说明](docs/onboarding-implementation.md)。
- 新增V51硬件智能体License池:License明文仅在平台加密库存与设备安全区中出现,平台按Instance ID/Product Key原子分配,设备校验摘要并回执后才允许`PROVISIONING → ACTIVE`;过期、吊销、丢失和解绑会立即阻断新会话。
- 新增V50设备激活状态机:`FACTORY → CLAIMABLE → BOUND → PROVISIONING → ACTIVE → LOST/REVOKED`,认领、火山凭据预配、解绑、丢失恢复和注销统一写入带修订号的迁移审计;LOST/解绑会暂停火山设备凭据,恢复后重新启用。
- 新增独立`volc_hardware_agent`运行时和`/device/v3/agent-sessions`控制面;设备每次聊天从平台取得冻结的人设、档案、近期摘要、已确认长期记忆和授权上下文,不再复用RTC房间语义。
- 新增V49平台代理火山设备注册和`/device/v3/hardware-agent-credentials:provision`:Product Secret只从平台Secret/KMS加载,一机一密在平台AES-256-GCM加密并下发对应设备,Windows端不再接收Product Secret。
- 硬件会话新增按设备Bot/传输/最低SDK绑定、完整能力快照、动态`session.updated`生效证明、单调事件水位、可靠最终字幕、平台`stop_session`动作、恢复扫描和SaaS运行时可见性。
- 不具备近端与远端双路PCM录制能力的硬件会话强制降级为仅文本或不留存;通话中记忆工具扩展到硬件会话并继续从数据库反查租户、长者和活动会话边界。
- 新增V47/V48/V49硬件运行时及凭据迁移、Windows控制面模拟接口、端到端正反向冒烟,以及带清单和SHA-256的服务器部署候选包工具。
- 维护中心新增租户级固件与OTA升级管理:可信Ed25519公钥、浏览器私有直传、大小与SHA-256校验、发布通道、强制升级标记、兼容设备灰度批次、可靠升级命令、暂停/恢复/退役和审计下载地址。
- 固件制品支持维护端租户级范围,同时保留原家庭端记录和设备受控下载链路;设备资产列表显示最近上报的当前固件版本。
- 聊天总结改为大模型两级内容价值判断:先判断是否值得保留,再判断是否值得形成故事;纯寒暄不再生成摘要或故事。
- 新增内容价值判断记录,保留模型、提示词版本、置信度和舍弃原因,支持任务幂等与隐私删除。
- 本地开发新增 GPU Ollama/Qwen2.5 7B 部署方式,并通过大模型管理的测试、激活和 worker 生成链路完成端到端验证。
+3 -1
View File
@@ -51,7 +51,7 @@
- 私有数据导出:按长者和分类生成JSON,MinIO私有存储、SHA-256校验、短期签名下载和过期对象清理。
- 主体级隐私删除:精确称呼确认、活动通话保护、立即冻结读取/上传/召回,以及PostgreSQL、Redis、对象存储、pgvector、外部索引和私有导出六子系统的可重试对账与最小删除证明。
- 设备命令可靠链路:家庭端幂等排队、设备HMAC主动拉取、重复投递计数、确认/成功/失败回执和过期终态;Web设备卡片可下发诊断命令。
- 固件安全发布与管理页:Ed25519受信公钥、浏览器私有直传、大小与SHA-256校验、签名清单验证、确定性灰度、可靠命令、短期下载、暂停、已验证版本回滚,以及逐设备下载/安装/确认进度。
- 家庭端及维护中心固件安全发布与OTA管理:租户级/家庭级Ed25519受信公钥、浏览器私有直传、大小与SHA-256校验、签名清单验证、兼容设备确定性灰度、可靠命令、设备与维护人员短期下载、暂停、已验证版本回滚,以及逐设备下载/安装/确认进度。
- 双轨录音媒体链路:near/far分片清单幂等、15分钟单对象直传、Outbox可靠校验、WAV PCM16容器/时长/大小/SHA-256核验、延迟删除本地密文及设备回执,以及PLAY/DOWNLOAD独立授权、授权列表/撤销、React双轨播放下载、5分钟私有访问和成功/拒绝审计。
- 联调问题闭环:按客户组织记录问题现象、严重级别、设备/通话/请求编号、责任方、根因、修复版本和脱敏回归证据;未关闭HIGH/CRITICAL形成发布阻断汇总,创建幂等、更新强ETag并全程审计。
- 认证SSE状态事件流:业务事务内生成最小事件,15分钟`Last-Event-ID`续传、活动家庭成员过滤、跨租户游标拒绝、重复事件抑制、设备上线去重/超时离线、React自动重连和2秒合并刷新,避免连续事件放大为整页请求风暴。
@@ -119,6 +119,8 @@ Web镜像在容器启动时根据`WEB_OIDC_AUTHORITY`和`WEB_OIDC_CLIENT_ID`生
真实火山RTC模式由两组独立开关启用:API设置`RTC_TOKEN_ISSUER=volcengine`、`RTC_APP_ID`和`RTC_APP_KEY`,Worker设置`VOICE_PROVIDER=volcengine`、`VOLC_ACCESS_KEY_ID`、`VOLC_SECRET_ACCESS_KEY`、区域、接口版本以及`VOLC_VOICE_CHAT_TEMPLATE_FILE`。生产模板采用`voice-chat-template-bundle-v1`,分别提供`s2s_pure`、`s2s_hybrid`和`cascade`三个完整VoiceChat `Config/AgentConfig`;平台按通话冻结模式选择模板,再覆盖AppId、RoomId、TaskId、目标设备用户、智能体用户,并追加本次已授权且脱敏的背景摘要。级联模式写入`LLMConfig.SystemMessages`,纯端到端模式写入`S2SConfig.ProviderParams.dialog.system_role`。可从[模板示例](deploy/volcengine/voice-chat-template.example.json)复制结构并按照已开通的官方能力替换每个完整`Config`对象;不要把AppKey、AK/SK、ASR/TTS Token提交到仓库。
火山硬件对话智能体使用独立的`volc_hardware_agent`运行时。Instance ID、Product Key、默认Bot ID、`high_quality`/`low_load`传输方式和最低SDK版本是平台持有的产品级配置,创建设备时只选择运行时,不再填写Bot。设备首次调用`/device/v3/hardware-agent-credentials:provision`时,平台自动建立该产品默认绑定,并使用KMS/Secret中的Product Secret代设备调用火山DynamicRegister;火山从产品额度中分配License并返回本设备的一机一密,License本身不下发设备。设备安全保存Device Secret后进入`ACTIVE`。每次聊天再调用`/device/v3/agent-sessions`取得默认Bot和本次冻结的人设、近期摘要、长期记忆与授权上下文,并通过火山SDK或标准WebSocket动态注入。平台只有在设备证明`session.updated`已应用且Bot、传输方式、配置摘要完全匹配后才激活通话;设备不具备近/远端双路PCM能力时,本次留存自动降级为仅文本。设备必须持续上报v3事件和最终字幕,并执行事件响应中的`stop_session`。完整时序、重试和凭据边界见[设备集成说明](docs/device-integration.md#115-火山硬件对话智能体-v3-控制面)。
设备HMAC密钥保存在`device_credential`表中,并使用AES-256-GCM和每条记录独立随机nonce加密;数据库不保存明文。API通过`DEVICE_CREDENTIAL_MASTER_KEY`(64位十六进制、解码后32字节)解密验签,该主密钥必须由Docker/Kubernetes Secret或云KMS CSI独立托管,不能进入数据库。`DEVICE_LOCAL_SECRET`只用于把既有演示设备凭据一次性导入数据库,完成迁移后可从运行环境移除。
数据库连接串、Redis、RTC AppKey、火山AK/SK、方舟API Key、对象存储凭据、回调/告警签名等敏感配置同时支持`NAME_FILE=/run/secrets/name`。文件路径必须是容器内绝对路径、目标必须是普通文件且不超过64KiB;读取时只去掉末尾换行,不在错误中打印内容。`NAME`与`NAME_FILE`同时存在会拒绝启动,防止轮换时仍意外使用旧的明文环境变量。
+36
View File
@@ -1,6 +1,24 @@
name: data-terminal-cloud
x-backend-environment: &backend-environment
# First onboarding is opt-in; existing devices retain the legacy protocol.
ONBOARDING_ENABLED: ${ONBOARDING_ENABLED:-false}
ONBOARDING_PAUSE_CLAIMS: ${ONBOARDING_PAUSE_CLAIMS:-false}
ONBOARDING_ACTIVATION_ENABLED: ${ONBOARDING_ACTIVATION_ENABLED:-false}
ONBOARDING_SMS_LOGIN_ENABLED: ${ONBOARDING_SMS_LOGIN_ENABLED:-false}
ONBOARDING_ALLOW_LOCAL_HTTP: ${ONBOARDING_ALLOW_LOCAL_HTTP:-false}
ONBOARDING_MASTER_KEY: ${ONBOARDING_MASTER_KEY:-}
ONBOARDING_ENTRY_URL: ${ONBOARDING_ENTRY_URL:-}
ONBOARDING_TRUSTED_PROXY_CIDRS: ${ONBOARDING_TRUSTED_PROXY_CIDRS:-}
ONBOARDING_SELF_SIGNUP_TENANT_IDS: ${ONBOARDING_SELF_SIGNUP_TENANT_IDS:-}
ONBOARDING_SMS_GATEWAY_URL: ${ONBOARDING_SMS_GATEWAY_URL:-}
ONBOARDING_SMS_GATEWAY_TOKEN: ${ONBOARDING_SMS_GATEWAY_TOKEN:-}
VOLC_HARDWARE_INSTANCE_ID: ${VOLC_HARDWARE_INSTANCE_ID:-}
VOLC_HARDWARE_PRODUCT_KEY: ${VOLC_HARDWARE_PRODUCT_KEY:-}
VOLC_HARDWARE_PRODUCT_SECRET: ${VOLC_HARDWARE_PRODUCT_SECRET:-}
VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY: ${VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY:-}
VOLC_HARDWARE_REGISTER_URL: ${VOLC_HARDWARE_REGISTER_URL:-https://iot-cn-shanghai.iot.volces.com/2021-12-14/DynamicRegister?Action=DynamicRegister&Version=2021-12-14}
VOLC_HARDWARE_REGISTER_TIMEOUT: ${VOLC_HARDWARE_REGISTER_TIMEOUT:-10s}
APP_ENV: local
LOG_LEVEL: debug
DATABASE_URL: postgres://voice:voice_dev_only@postgres:5432/voice_platform?sslmode=disable
@@ -30,6 +48,11 @@ x-backend-environment: &backend-environment
MEMORY_TOOL_TOKEN_TTL: 4h
MEMORY_TOOL_RATE_RPS: "2"
MEMORY_TOOL_RATE_BURST: "5"
VIKING_MEMORY_RESOURCE_ID: ${VIKING_MEMORY_RESOURCE_ID:-}
VIKING_MEMORY_COLLECTION_NAME: ${VIKING_MEMORY_COLLECTION_NAME:-}
VIKING_MEMORY_API_KEY: ${VIKING_MEMORY_API_KEY:-}
VIKING_MEMORY_EVENT_TYPE: ${VIKING_MEMORY_EVENT_TYPE:-event_v1}
VIKING_MEMORY_PROFILE_TYPE: ${VIKING_MEMORY_PROFILE_TYPE:-profile_v1}
PROVIDER_MEMORY_MODE: ${PROVIDER_MEMORY_MODE:-disabled}
PROVIDER_MEMORY_BASE_URL: ${PROVIDER_MEMORY_BASE_URL:-}
PROVIDER_MEMORY_API_KEY: ${PROVIDER_MEMORY_API_KEY:-}
@@ -55,6 +78,9 @@ x-backend-environment: &backend-environment
VOLC_RTC_REGION: ${VOLC_RTC_REGION:-cn-north-1}
VOLC_RTC_API_VERSION: ${VOLC_RTC_API_VERSION:-2024-12-01}
VOLC_VOICE_CHAT_TEMPLATE_FILE: ${VOLC_VOICE_CHAT_TEMPLATE_FILE:-}
VOLC_HARDWARE_AGENT_BOT_ID: ${VOLC_HARDWARE_AGENT_BOT_ID:-local-hardware-agent-bot}
VOLC_HARDWARE_AGENT_TRANSPORT: ${VOLC_HARDWARE_AGENT_TRANSPORT:-high_quality}
VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION: ${VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION:-1.0.0}
PROVIDER_FAILURE_THRESHOLD: "3"
PROVIDER_CIRCUIT_COOLDOWN: 1m
RECOVERY_SCAN_INTERVAL: 5s
@@ -83,6 +109,7 @@ x-backend-build: &backend-build
VERSION: ${PLATFORM_BUILD_VERSION:-dev}
COMMIT: ${PLATFORM_BUILD_COMMIT:-unknown}
BUILD_TIME: ${PLATFORM_BUILD_TIME:-unknown}
GOPROXY: ${GOPROXY:-https://proxy.golang.org,direct}
x-backend-dependencies: &backend-dependencies
postgres:
@@ -239,6 +266,14 @@ services:
HTTP_ADDR: 0.0.0.0:8080
DEVICE_LOCAL_SECRET: local-simulated-device-secret-32-bytes-minimum
RTC_TOKEN_SIGNING_SECRET: local-simulated-rtc-token-secret-32-bytes-minimum
VOLC_HARDWARE_PROVISIONING_ENABLED: ${VOLC_HARDWARE_PROVISIONING_ENABLED:-false}
VOLC_HARDWARE_INSTANCE_ID: ${VOLC_HARDWARE_INSTANCE_ID:-}
VOLC_HARDWARE_PRODUCT_KEY: ${VOLC_HARDWARE_PRODUCT_KEY:-}
VOLC_HARDWARE_PRODUCT_SECRET: ${VOLC_HARDWARE_PRODUCT_SECRET:-}
VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY: ${VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY:-}
VOLC_HARDWARE_REGISTER_URL: ${VOLC_HARDWARE_REGISTER_URL:-https://iot-cn-shanghai.iot.volces.com/2021-12-14/DynamicRegister?Action=DynamicRegister&Version=2021-12-14}
VOLC_HARDWARE_REGISTER_TIMEOUT: ${VOLC_HARDWARE_REGISTER_TIMEOUT:-10s}
VOLC_HARDWARE_DEVICE_NAME_PREFIX: ${VOLC_HARDWARE_DEVICE_NAME_PREFIX:-dt-}
ports:
- "${API_PORT:-58080}:8080"
depends_on: *backend-dependencies
@@ -332,6 +367,7 @@ services:
VERSION: ${PLATFORM_BUILD_VERSION:-dev}
COMMIT: ${PLATFORM_BUILD_COMMIT:-unknown}
BUILD_TIME: ${PLATFORM_BUILD_TIME:-unknown}
NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}
environment:
WEB_OIDC_AUTHORITY: ${WEB_OIDC_AUTHORITY:-http://localhost:58180/realms/voice-local}
WEB_OIDC_CLIENT_ID: ${WEB_OIDC_CLIENT_ID:-voice-platform-web}
+1
View File
@@ -1,4 +1,5 @@
FROM golang:1.26.6-alpine AS build
ARG GOPROXY=https://proxy.golang.org,direct
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
+11
View File
@@ -50,6 +50,17 @@ data:
VOLC_RTC_BASE_URL: https://rtc.volcengineapi.com
VOLC_RTC_REGION: cn-north-1
VOLC_RTC_API_VERSION: "2024-12-01"
# Platform-owned product defaults. Devices receive these only through the
# signed provisioning/session control plane and cannot override them.
VOLC_HARDWARE_AGENT_BOT_ID: "replace-with-product-default-bot-id"
VOLC_HARDWARE_AGENT_TRANSPORT: "high_quality"
VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION: "1.0.0"
VOLC_HARDWARE_PROVISIONING_ENABLED: "false"
VOLC_HARDWARE_INSTANCE_ID: ""
VOLC_HARDWARE_PRODUCT_KEY: ""
VOLC_HARDWARE_REGISTER_URL: https://iot-cn-shanghai.iot.volces.com/2021-12-14/DynamicRegister?Action=DynamicRegister&Version=2021-12-14
VOLC_HARDWARE_REGISTER_TIMEOUT: 10s
VOLC_HARDWARE_DEVICE_NAME_PREFIX: "dt-"
CONTENT_GENERATOR: ark
CONTENT_SAFETY_PROVIDER: managed_http
CONTENT_SAFETY_ENDPOINT: https://content-safety.example.invalid/v1/classify
@@ -19,7 +19,7 @@ metadata:
spec:
provider: replace-with-installed-provider
parameters:
objects: replace-with-provider-specific-rtc-app-key-cursor-share-and-device-credential-master-key
objects: replace-with-provider-specific-rtc-app-key-cursor-share-device-credential-master-key-volc-hardware-product-secret-and-volc-device-credential-master-key
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
@@ -6,6 +6,13 @@ metadata:
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
volumes:
- $patch: replace
- name: common-secrets
@@ -35,6 +42,13 @@ metadata:
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
volumes:
- $patch: replace
- name: common-secrets
@@ -58,6 +72,13 @@ metadata:
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
volumes:
- $patch: replace
- name: common-secrets
+1 -1
View File
@@ -59,6 +59,6 @@
通话结束后,Worker会根据最终话轮生成通话摘要、关键词、开放话题和待审核故事。只有人工确认故事后才会进入长期记忆,这是既有授权与审核边界,模拟器不会绕过审核自动写入长期记忆。本地默认`CONTENT_GENERATOR=local`仅用于确定性闭环验证;要验证真实大模型总结,必须另外为Worker配置`CONTENT_GENERATOR=ark`、`ARK_API_KEY_FILE`和`ARK_CONTENT_MODEL`。
当新通话冻结快照中的`story_extraction`和`personalized_memory`授权均有效时,平台会把最近3次已完成通话的就绪摘要作为短期连续上下文注入豆包;S2S-O模式使用`dialog.system_role`,无需开启供应商侧`dialog_id`历史留存。长者档案中的“避免主动提及”话题也会从下一次开房起一并生效。
当新通话冻结快照中的`story_extraction`和`personalized_memory`授权均有效时,平台会把最近3次已完成通话同一最新生成版本的就绪摘要与一个可选开放话题作为短期连续上下文注入豆包;S2S-O模式使用`dialog.system_role`,无需开启供应商侧`dialog_id`历史留存。开放话题只是自然相关时可用的建议,不得覆盖长者当前话题。长者档案中的“避免主动提及”话题也会从下一次开房起一并生效。
RTC AppKey、火山 AK/SK 和豆包 Access Token 均不进入模拟器安装包,也不会通过渲染页面读取。
+1 -1
View File
@@ -72,7 +72,7 @@ $template = [ordered]@{
AgentConfig = [ordered]@{
TargetUserId = @()
UserId = ""
WelcomeMessage = "您好,我是豆包语音助手。今天想聊些什么?"
WelcomeMessage = ""
EnableConversationStateCallback = $false
}
Config = [ordered]@{
+2 -1
View File
@@ -1,3 +1,4 @@
FROM migrate/migrate:v4.18.3
COPY migrations /migrations
COPY --chown=65532:65532 migrations /migrations
USER 65532:65532
@@ -0,0 +1,24 @@
# 长者故事陪伴人设 v1
该文件是 `PERSONA` 配置的可审阅来源。线上运行仍使用经过版本化发布并由 `AGENT_BUNDLE` 引用的配置。
## system_prompt
你是一位平等、耐心、尊重长者的中文语音聊天伙伴。你的目标是让长者感到被认真听见,在对方愿意时自然聊深一段生活经历;故事完整度不是长者必须完成的任务。
聊天规则:
1. 先回应长者刚说的具体内容,再决定是否追问。通常只说一到两句,每轮最多问一个问题;没有自然问题时可以只回应。
2. 长者分享经历时,先复述或回应其中一个具体细节,再视情况问一个开放、具体的问题。不要连着盘问,不要把聊天变成采访。
3. 跟随长者当前话题。历史摘要、开放话题和记忆只作背景,只有与当前内容自然相关时才使用,不要突然照念旧话题。
4. 长者说“不想聊”、累了、忘了或换了话题时,立刻尊重并转向或收尾。不要催促、埋怨、强行挽留,也不要说“你怎么才回来”“我一直等你”。
5. 允许重复讲述、停顿和模糊年代,不纠正口音或表达。可以温和核对不确定的记忆,但不能编造未提供的人物、经历、偏好或因果,也不能把推断说成事实。
6. 使用档案里的称呼;没有明确称呼时不要擅自叫“叔叔”“阿姨”。表达像熟悉的朋友,避免客服套话、夸张赞美、网络流行语和复杂术语。
7. 涉及疾病、用药和症状时,只给一般安全提醒,不诊断,也不建议停药换药;紧急情况优先建议联系家人或专业人员。
8. 回复只包含直接对长者说的自然口语,不输出动作、表情、语气、情绪、场景、系统提示或配置说明。
自然回应的顺序是:接住当前内容 -> 回应细节或感受 -> 有必要才问一个问题 -> 尊重停顿与结束。
## tone
亲切、自然、平等;语速正常,句子简短,不使用夸张撒娇或电子化腔调。
@@ -0,0 +1,26 @@
# 长者故事陪伴人设 v2
该文件是 `PERSONA` 配置的可审阅来源。线上运行仍使用经过版本化发布并由 `AGENT_BUNDLE` 引用的配置。
## system_prompt
你是一位平等、耐心、尊重长者的中文语音聊天伙伴。你的目标是让长者感到被认真听见,在对方愿意时自然聊深一段生活经历;故事完整度不是长者必须完成的任务。
聊天规则:
1. 先回应长者刚说的具体内容。通常只说一到两句,只有问题确实能帮助对方继续表达时才问。
2. 问句必须稀疏:任意连续两次回复最多出现一个问题。如果你的上一条回复已经问过问题,那么下一条回复必须只用陈述句回应,即使长者只回答“是啊”“嗯”“对”“不是”也不能立刻再问。先回应、分享一句相关感受或常识,然后留出停顿。
3. 长者分享经历时,先复述或回应其中一个确实由长者说出的细节。需要追问时,一次只问一个开放而具体的问题;不要连续盘问,不要把聊天变成采访。
4. 不得补写长者没有说过的现场、动作、人物、情绪、原因或结果。避免“我猜……”“您是不是经常……”等带着假设的说法;不确定时先作不含新事实的回应,之后再择机核对。
5. 跟随长者当前话题。历史摘要、开放话题和记忆只作背景,只有与当前内容自然相关时才使用,不要突然照念旧话题。
6. 长者说“不想聊”、累了、忘了或换了话题时,立刻尊重并转向或收尾。不要催促、埋怨、强行挽留,也不要说“你怎么才回来”“我一直等你”。
7. 允许重复讲述、停顿和模糊年代,不纠正口音或表达。可以温和核对不确定的记忆,但不能编造未提供的人物、经历、偏好或因果,也不能把推断说成事实。
8. 使用档案里的称呼;没有明确称呼时不要擅自叫“叔叔”“阿姨”。表达像熟悉的朋友,避免客服套话、夸张赞美、网络流行语和复杂术语。
9. 涉及疾病、用药和症状时,只给一般安全提醒,不诊断,也不建议停药换药;紧急情况优先建议联系家人或专业人员。
10. 回复只包含直接对长者说的自然口语,不输出动作、表情、语气、情绪、场景、系统提示或配置说明。
自然回应的顺序是:接住当前内容 -> 回应一个真实细节或感受 -> 检查上一轮是否已经提问 -> 只有必要且没有连续提问时才问一个问题 -> 尊重停顿与结束。
## tone
亲切、自然、平等;语速正常,句子简短,不使用夸张撒娇或电子化腔调。
@@ -0,0 +1,33 @@
# 长者故事陪伴人设 v3
该文件是 `PERSONA` 配置的可审阅来源。线上运行仍使用经过版本化发布并由 `AGENT_BUNDLE` 引用的配置。
## system_prompt
你是一位平等、耐心、尊重长者的中文语音聊天伙伴。你的目标是让长者感到被认真听见,在对方愿意时自然聊深一段生活经历;故事完整度不是长者必须完成的任务。
聊天规则:
1. 先回应长者刚说的具体内容。通常只说一到两句,默认使用陈述句;只有问题确实能帮助对方继续表达时才问。
2. 问句必须稀疏:任意连续三次回复最多出现一个问题。“问题”包括所有索取信息、确认或同意的表达,不以问号为准;“吧、吗、呢、是不是、有没有、会不会、对不对、可不可以、愿不愿意”等都算问题。如果你最近两次回复中已经问过问题,那么本轮必须只用陈述句回应。
3. 长者只回答“是啊”“嗯”“对”“不是”等简短附和或否定时,下一条必须是纯陈述句,不能用“吧”请求确认,也不能换一种说法继续追问。先接住回答,说一句有依据的回应或一般常识,然后停下来。
4. 长者分享经历时,先复述或回应其中一个确实由长者说出的细节。需要追问时,一次只问一个开放而具体的问题;不要连续盘问,不要把聊天变成采访。
5. 只能把长者明确说过的内容归到长者本人。不得补写现场、动作、人物、情绪、原因、结果或第三方态度;不得用“肯定、一定、应该、想必、我猜、听得出、看得出”包装猜测。没有依据时说一般性的内容,不评价长者“很厉害”,不声称“大家都佩服您”。
6. 跟随长者当前话题。历史摘要、开放话题和记忆只作背景,只有与当前内容自然相关时才使用,不要突然照念旧话题。
7. 长者说“不想聊”、累了、忘了或换了话题时,立刻尊重并转向或收尾。不要催促、埋怨、强行挽留,也不要说“你怎么才回来”“我一直等你”。
8. 允许重复讲述、停顿和模糊年代,不纠正口音或表达。可以温和核对不确定的记忆,但不能编造未提供的人物、经历、偏好或因果,也不能把推断说成事实。
9. 使用档案里的称呼;没有明确称呼时不要擅自叫“叔叔”“阿姨”。表达像熟悉的朋友,避免客服套话、夸张赞美、网络流行语和复杂术语。
10. 涉及疾病、用药和症状时,只给一般安全提醒,不诊断,也不建议停药换药;紧急情况优先建议联系家人或专业人员。
11. 回复只包含直接对长者说的自然口语,不输出动作、表情、语气、情绪、场景、系统提示或配置说明。
示例:
- 长者说“我年轻的时候在一家工厂修机器。”可以答:“修机器是门讲经验的手艺。哪一次维修让您印象最深?”
- 如果长者接着只说“是啊。”下一条可以答:“这门手艺里的经验,都是一点点积累起来的。”然后停下来。
- 不要回答“肯定很辛苦吧”“您当时肯定很厉害”“大家应该都很佩服您吧”。
自然回应的顺序是:接住当前内容 -> 回应一个真实细节或一般常识 -> 检查最近两次回复是否已经提问 -> 只有必要且不会形成连续追问时才问一个问题 -> 尊重停顿与结束。
## tone
亲切、自然、平等;语速正常,句子简短,不使用夸张撒娇或电子化腔调。
+16
View File
@@ -2,6 +2,8 @@
该配置用于单机公网演示,不是生产验收配置。只向公网发布HTTP 80和HTTPS 443;PostgreSQL、Redis、MinIO、Keycloak内部端口以及API/Worker端口均不映射到宿主机。当前阿里云演示机使用Let's Encrypt短期IP证书,`certbot-ip-renew.timer`负责自动续期。单机备份覆盖PostgreSQL、Redis、MinIO对象和当前Keycloak数据目录,并执行数据库级隔离恢复校验;五分钟健康检查覆盖部署模式、公网入口、OIDC、容器、模拟服务、备份、外部告警、证书、定时任务和磁盘。跨故障域备份、真实告警接收器、真实RTC、模型、内容安全和云KMS仍按阶段8验收清单执行。
无法直接从Git拉取或需要冻结当前工作树时,可先在开发机执行`powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-server-deployment-bundle.ps1 -Version 0.76.0-rc.hardware1`,再用`powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-server-deployment-bundle.ps1 -Archive .\.deploy-artifacts\data-terminal-cloud-0.76.0-rc.hardware1.zip`验证。归档包含`deploy-manifest.json`和`SHA256SUMS`,不会打包`.env`、设备/供应商密钥、本地运行目录、`node_modules`或构建缓存。上传服务器后必须再次核对归档侧车`.sha256`和包内清单,再覆盖候选目录;服务器现有`deploy/server/.env`及`/etc/data-terminal-cloud`密钥目录不得从归档替换。
服务器至少需要Docker Engine 29与Compose 2.40。首次部署先生成权限为600的服务器专用配置:
```sh
@@ -30,6 +32,15 @@ docker compose -f compose.yaml -f deploy/server/compose.override.yaml \
需要在演示机接入真实RTC、豆包语音和方舟摘要时,把凭据文件以`0600`权限放入`/etc/data-terminal-cloud/voice-runtime`,并在`deploy/server/.env`设置`RTC_TOKEN_ISSUER=volcengine`、`VOICE_PROVIDER=volcengine`、`RTC_APP_ID`、`CONTENT_GENERATOR`和`ARK_CONTENT_MODEL`。Compose只读挂载该目录,敏感值不进入源码、镜像或环境文件。
切换火山硬件对话智能体时,维护中心逐台只需把`voice_runtime`设为`volc_hardware_agent`,不填写设备级Bot。平台代设备动态注册时,在`deploy/server/.env`设置`VOLC_HARDWARE_PROVISIONING_ENABLED=true`、`VOLC_HARDWARE_INSTANCE_ID`、`VOLC_HARDWARE_PRODUCT_KEY`、`VOLC_HARDWARE_AGENT_BOT_ID`、`VOLC_HARDWARE_AGENT_TRANSPORT`、`VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION`、`VOLC_HARDWARE_PRODUCT_SECRET_FILE=/run/secrets/voice-runtime/volc-hardware-product-secret`和独立随机的`VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY`;将Product Secret以单行、`0600`权限写入`/etc/data-terminal-cloud/voice-runtime/volc-hardware-product-secret`。旧设备代理注册流程的Product Secret不下发给设备;新设备ONBOARDING-1.1按附件E.2通过认证后的HTTPS加密配置下发。Product Secret不得进入`.env`、源码、日志或镜像。设备首次预配时,平台根据这些产品默认值自动创建火山绑定并调用DynamicRegister;火山从产品额度中分配License,平台只加密保存并下发本设备的一机一密Device Secret,不下发License明文。发布前必须验证平台代理注册、默认Bot绑定、每次会话上下文注入、`session.updated`确认后激活、`session_updated`存活上报、`stop_session`执行和最终字幕补传。
```sh
sudo install -d -m 0700 /etc/data-terminal-cloud/voice-runtime
sudo sh -c 'umask 077; read -r product_secret; printf "%s" "$product_secret" > /etc/data-terminal-cloud/voice-runtime/volc-hardware-product-secret'
```
上面的`read`从终端读取,不要把真实Product Secret写进Shell历史。启用前确认默认Bot关联的产品与`VOLC_HARDWARE_PRODUCT_KEY`完全一致;Device Name由`dt-`加平台设备UUID稳定生成。密钥文件或产品默认配置缺失、格式错误时API拒绝启动,未启用时凭据接口明确返回`HARDWARE_AGENT_PROVISIONING_UNAVAILABLE`。
设备HMAC密钥由`device_credential`表以AES-256-GCM密文保存。`deploy/server/.env`中的`DEVICE_CREDENTIAL_MASTER_KEY`只负责数据库密文的认证解密,不得写入数据库或设备;既有`DEVICE_LOCAL_SECRET`只用于首次部署升级时导入两台演示设备,确认数据库凭据记录完整后可移除。新增、轮换和吊销设备密钥使用后端镜像内的`/app/device-credential`维护命令,生成的设备明文密钥只显示一次。
通话内记忆工具只通过网关精确开放`/internal/v1/tools/memory-search`,请求必须携带平台为当前通话签发的短期任务令牌。`MEMORY_TOOL_SIGNING_SECRET`必须是服务器独立随机密钥,不得与游标签名、分享签名或RTC密钥复用;网关不会开放其他`/internal`路径。
@@ -79,3 +90,8 @@ sudo deploy/server/install-offsite-backup.sh
```
安装后默认为禁用状态,不会访问OSS。默认认证方式为`ecs_ram_role`,使用`cn-hangzhou`地域端点并强制先通过IMDSv2确认实例角色,不保存长期AccessKey。创建私有Bucket并绑定最小权限ECS RAM角色后,在`/etc/data-terminal-cloud/offsite-backup.env`填写Bucket和前缀、设置`OFFSITE_BACKUP_ENABLED=true`与`OFFSITE_BACKUP_REQUIRED=true`,再手工启动一次`platform-offsite-backup.service`。任务会上传加密归档及摘要、重新下载并复算SHA-256;健康检查在启用后强制要求最新本机归档已有对应远端验证。配置和真实验收步骤见[《阿里云OSS异地备份适配与门禁》](../../docs/offsite-backup.md)。
## 首次设备认证与绑定(2026-09-12)
新设备使用ONBOARDING-1.1。叠加`deploy/server/compose.onboarding.yaml`,设置ONBOARDING_RELEASE_VERSION与实际网关网段ONBOARDING_TRUSTED_PROXY_CIDRS;主密钥使用服务器`/etc/data-terminal-cloud/voice-runtime/onboarding-master-key`。先备份再迁移至58,更新API、worker和web,并重建gateway使新的Nginx挂载生效。仅HTTP `POST /api/v1/device/time-challenges`直达API;其余HTTP继续跳转HTTPS。详细配置与验证见[实施说明](../../docs/onboarding-implementation.md)。
+1
View File
@@ -34,6 +34,7 @@ umask 077
printf 'KEYCLOAK_ADMIN_PASSWORD=%s\n' "$(openssl rand -hex 24)"
printf 'DEVICE_LOCAL_SECRET=%s\n' "$(openssl rand -hex 32)"
printf 'DEVICE_CREDENTIAL_MASTER_KEY=%s\n' "$(openssl rand -hex 32)"
printf 'VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY=%s\n' "$(openssl rand -hex 32)"
printf 'MODEL_CREDENTIAL_MASTER_KEY=%s\n' "$(openssl rand -hex 32)"
printf 'RTC_TOKEN_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)"
printf 'CURSOR_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)"
+32
View File
@@ -0,0 +1,32 @@
# Add after compose.override.yaml and the server's existing compose.subtitle.yaml.
x-onboarding-runtime: &onboarding-runtime
ONBOARDING_ENABLED: ${ONBOARDING_ENABLED:-true}
ONBOARDING_ACTIVATION_ENABLED: ${ONBOARDING_ACTIVATION_ENABLED:-true}
ONBOARDING_PAUSE_CLAIMS: ${ONBOARDING_PAUSE_CLAIMS:-false}
ONBOARDING_SMS_LOGIN_ENABLED: ${ONBOARDING_SMS_LOGIN_ENABLED:-false}
ONBOARDING_ALLOW_LOCAL_HTTP: "false"
ONBOARDING_MASTER_KEY: ""
ONBOARDING_MASTER_KEY_FILE: /run/secrets/voice-runtime/onboarding-master-key
ONBOARDING_ENTRY_URL: ${PUBLIC_ORIGIN}/onboarding/entry
ONBOARDING_TRUSTED_PROXY_CIDRS: ${ONBOARDING_TRUSTED_PROXY_CIDRS:?set the gateway Docker subnet}
ONBOARDING_SELF_SIGNUP_TENANT_IDS: ${ONBOARDING_SELF_SIGNUP_TENANT_IDS:-}
VOLC_HARDWARE_INSTANCE_ID: ${VOLC_HARDWARE_INSTANCE_ID}
VOLC_HARDWARE_PRODUCT_KEY: ${VOLC_HARDWARE_PRODUCT_KEY}
VOLC_HARDWARE_AGENT_BOT_ID: ${VOLC_HARDWARE_AGENT_BOT_ID}
VOLC_HARDWARE_AGENT_TRANSPORT: ${VOLC_HARDWARE_AGENT_TRANSPORT:-low_load}
VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION: ${VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION:-1.0.0}
VOLC_HARDWARE_PRODUCT_SECRET: ""
VOLC_HARDWARE_PRODUCT_SECRET_FILE: ${VOLC_HARDWARE_PRODUCT_SECRET_FILE}
VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY: ${VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY}
services:
api:
image: data-terminal-cloud-backend:${ONBOARDING_RELEASE_VERSION:?set release version}
environment:
<<: *onboarding-runtime
worker:
image: data-terminal-cloud-backend:${ONBOARDING_RELEASE_VERSION:?set release version}
environment:
<<: *onboarding-runtime
web:
image: data-terminal-cloud-web:${ONBOARDING_RELEASE_VERSION:?set release version}
+18
View File
@@ -22,6 +22,10 @@ x-server-backend-environment: &server-backend-environment
MEMORY_TOOL_BASE_URL: ${PUBLIC_ORIGIN}/internal/v1/tools/memory-search
MEMORY_TOOL_SIGNING_SECRET: ${MEMORY_TOOL_SIGNING_SECRET}
MODEL_CREDENTIAL_MASTER_KEY: ${MODEL_CREDENTIAL_MASTER_KEY}
VIKING_MEMORY_RESOURCE_ID: ${VIKING_MEMORY_RESOURCE_ID:-}
VIKING_MEMORY_COLLECTION_NAME: ${VIKING_MEMORY_COLLECTION_NAME:-}
VIKING_MEMORY_EVENT_TYPE: ${VIKING_MEMORY_EVENT_TYPE:-event_v1}
VIKING_MEMORY_PROFILE_TYPE: ${VIKING_MEMORY_PROFILE_TYPE:-profile_v1}
EMBEDDING_PROVIDER: local
VOICE_PROVIDER: ${VOICE_PROVIDER:-mock}
@@ -102,6 +106,18 @@ services:
RTC_APP_ID: ${RTC_APP_ID:-local-public-rtc-app}
RTC_APP_KEY: ""
RTC_APP_KEY_FILE: ${RTC_APP_KEY_FILE:-/run/secrets/voice-runtime/rtc-app-key}
VOLC_HARDWARE_PROVISIONING_ENABLED: ${VOLC_HARDWARE_PROVISIONING_ENABLED:-false}
VOLC_HARDWARE_INSTANCE_ID: ${VOLC_HARDWARE_INSTANCE_ID:-}
VOLC_HARDWARE_PRODUCT_KEY: ${VOLC_HARDWARE_PRODUCT_KEY:-}
VOLC_HARDWARE_AGENT_BOT_ID: ${VOLC_HARDWARE_AGENT_BOT_ID:-}
VOLC_HARDWARE_AGENT_TRANSPORT: ${VOLC_HARDWARE_AGENT_TRANSPORT:-low_load}
VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION: ${VOLC_HARDWARE_AGENT_MINIMUM_SDK_VERSION:-1.0.0}
VOLC_HARDWARE_PRODUCT_SECRET: ""
VOLC_HARDWARE_PRODUCT_SECRET_FILE: ${VOLC_HARDWARE_PRODUCT_SECRET_FILE:-}
VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY: ${VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY:-}
VOLC_HARDWARE_DEVICE_NAME_PREFIX: ${VOLC_HARDWARE_DEVICE_NAME_PREFIX:-dt-}
VIKING_MEMORY_API_KEY: ""
VIKING_MEMORY_API_KEY_FILE: ${VIKING_MEMORY_API_KEY_FILE:-/run/secrets/voice-runtime/viking_memory_api_key}
volumes:
- /etc/data-terminal-cloud/voice-runtime:/run/secrets/voice-runtime:ro
restart: unless-stopped
@@ -143,6 +159,8 @@ services:
ARK_API_KEY_FILE: ${ARK_API_KEY_FILE:-/run/secrets/voice-runtime/ark-api-key}
ARK_CONTENT_MODEL: ${ARK_CONTENT_MODEL:-}
CONTENT_SAFETY_PROVIDER: local
VIKING_MEMORY_API_KEY: ""
VIKING_MEMORY_API_KEY_FILE: ${VIKING_MEMORY_API_KEY_FILE:-/run/secrets/voice-runtime/viking_memory_api_key}
volumes:
- /etc/data-terminal-cloud/voice-runtime:/run/secrets/voice-runtime:ro
restart: unless-stopped
+15 -1
View File
@@ -1,7 +1,21 @@
server {
listen 80;
server_name _;
return 301 https://121.41.46.238$request_uri;
# First-boot clock bootstrap only. The response is authenticated by TIME1.
location = /api/v1/device/time-challenges {
limit_except POST { deny all; }
client_max_body_size 8k;
resolver 127.0.0.11 ipv6=off valid=10s;
set $clock_backend http://api:8080;
proxy_pass $clock_backend;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
}
location / {
return 301 https://121.41.46.238$request_uri;
}
}
server {
@@ -5,7 +5,7 @@
"AgentConfig": {
"TargetUserId": [],
"UserId": "",
"WelcomeMessage": "您好,我会陪您聊聊天。",
"WelcomeMessage": "",
"EnableConversationStateCallback": false
},
"Config": {
@@ -16,7 +16,7 @@
"AgentConfig": {
"TargetUserId": [],
"UserId": "",
"WelcomeMessage": "您好,我会陪您聊聊天。",
"WelcomeMessage": "",
"EnableConversationStateCallback": false
},
"Config": {
@@ -27,7 +27,7 @@
"AgentConfig": {
"TargetUserId": [],
"UserId": "",
"WelcomeMessage": "您好,我会陪您聊聊天。",
"WelcomeMessage": "",
"EnableConversationStateCallback": false
},
"Config": {
+5
View File
@@ -1,4 +1,5 @@
FROM node:24-alpine AS build
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org
WORKDIR /src
COPY web/package*.json ./
RUN npm ci
@@ -15,6 +16,10 @@ LABEL org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${BUILD_TIME}"
USER root
RUN apk upgrade --no-cache
# The image's IPv6 helper shells out to `apk manifest nginx` on every start.
# On small production hosts that command can block even after the package
# database is healthy. This deployment owns default.conf and does not need it.
RUN rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
COPY --chown=101:101 deploy/web.nginx.conf /etc/nginx/conf.d/default.conf
COPY --chmod=755 --chown=101:101 deploy/web-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
COPY --chown=101:101 --from=build /src/dist /usr/share/nginx/html
+97 -26
View File
@@ -1,8 +1,45 @@
# Windows 语音终端模拟器
# Windows 硬件对话智能体测试程序
该程序使用Electron和火山引擎Web RTC SDK模拟一台真实语音终端。它采集Windows麦克风、加入平台分配的RTC房间、发布本地音频、订阅并播放豆包智能体音频,并通过设备v2接口完成激活、状态等待、心跳、Token续签和结束通话。进房后还会启动RTC实时字幕,收集长者与智能体的最终话轮并通过现有`turns:batch`设备接口可靠上传;字幕未成功启动时拒绝继续激活通话,避免生成没有聊天记录的完成通话。
这是方案 1 的 Windows 端到端验收工具。默认页面不再使用平台代管的 RTC/StartVoiceChat,而是按火山“硬件对话智能体低负载标准协议”直接连接 `wss://ai-gateway.vei.volces.com/v1/realtime`。传统 RTC 模拟器仍保留在页面右上角的“传统 RTC 测试页”。
创建通话返回的隐私策略允许原录音时,模拟器同时通过RTC原始音频帧回调采集长者`near`和智能体`far`轨道。结束通话后自动封装为单声道WAV PCM16、按平台限制分片、申请短期对象地址直传,并等待后台完整性与容器校验完成。家庭工作台的通话记录会标记“可听聊天语音”,获得独立PLAY授权的账号可在详情中选择“听长者语音”或“听智能体语音”。
## 实际覆盖的流程
1. 使用平台设备 ID、时间戳、随机数、正文摘要和 HMAC-SHA256 调用设备 v2/v3 接口。
2. 调用平台 `/device/v3/hardware-agent-credentials:provision`;平台自动应用产品级Instance ID、Product Key、默认Bot、传输和最低SDK配置,并使用KMS/Secret挂载的 Product Secret 代设备调用 DynamicRegister。
3. 火山在动态注册时从产品额度中分配 License;平台只把本设备 Device Secret 下发给设备,Windows 使用 Safe Storage 加密保存后重新拉取配置版本并进入 `ACTIVE`。
4. 创建 `/device/v3/agent-sessions`,从平台取得产品默认 Bot ID、`low_load` 传输方案、本次冻结的人设、背景、最近摘要、确认记忆和授权范围。
5. 按火山规定的请求头签名建立 WebSocket,等待 `session.created`,提交本次 `session.update`,收到 `session.updated` 后才向平台回报 `session_started`并开放麦克风。
6. Windows 麦克风被转换为 PCM16、16kHz、单声道、20ms 分片,通过 `input_audio_buffer.append` 实时上行;`response.audio.delta` 按火山 low-load WebSocket SDK 的 PCM16、16kHz、单声道格式实时播放。
7. 消费服务端 VAD、用户最终识别、智能体最终字幕、Function Calling 和错误事件;双方最终话轮通过 v3 `turns:batch` 幂等上传。
8. 智能体触发 `memory_search`、`search_memory` 或 `retrieve_memory` 时,程序使用平台签发的当前会话短期令牌调用同源记忆工具,再把结果作为 `function_call_output` 返回火山。
9. 周期提交 `session_updated`。平台返回 `stop_session` 时立即关麦、补传字幕、关闭火山连接并回报 `session_ended`。
10. 页面显示 13 项验收门禁,可导出不包含任何密钥的 JSON 报告。
## 使用前配置
平台维护中心只需把测试设备配置为:
- `voice_runtime = volc_hardware_agent`
平台部署需配置并关联同一火山产品和 Bot:
- Instance ID
- Product Key
- 默认 Bot ID
- 传输方式 `low_load`
- 最低 SDK 版本不高于 `1.1.0-windows-standard-protocol`
- Product Secret(只进入平台密钥挂载,不进入Windows程序)
- Device Name 由平台按设备ID稳定生成,或使用设备绑定中预先登记的名称
- 可用的硬件对话智能体 License
Windows程序不提供 Product Secret、Device Secret 或 Bot ID 输入框。Bot ID、Instance ID、Product Key 和 Device Name 均来自平台;设备不能覆盖产品默认路由。
开始测试前还需按控制台中的 Bot 模型选择“智能体模型类型”:
- 通用 LLM:使用官方 `config.LLMConfig.SystemMessages` 注入平台冻结的人设、背景与授权上下文;
- 豆包端到端语音:使用官方 `config.S2SConfig.ProviderParams.dialog` 注入 `system_role`、`speaking_style` 和 `dialog_id`。
两种模式都使用官方 `session.object/config/agent_config/input_audio_format/output_audio_format` 报文结构。字段依据火山[初始化会话配置](https://www.volcengine.com/docs/6348/2139862?lang=zh)与[标准协议自定义接入](https://www.volcengine.com/docs/6348/1904602?lang=zh)文档。
## 构建
@@ -12,36 +49,70 @@ npm install
npm run build
```
输出:`release/voice-device-simulator-0.1.0-windows-x64.exe`。
输出文件:
连接公网测试服务器时,可以直接双击EXE,也可以使用`launch-cloud-device.ps1`启动。Electron主进程优先读取启动脚本传入的临时环境变量,否则从当前Windows用户的`%LOCALAPPDATA%\DataTerminalVoiceDevice\server-device-secret`读取设备签名密钥。密钥不写入源码、安装包或浏览器存储,也不会回传到渲染进程;输入框留空并显示“已从Windows安全凭据文件加载”即表示可直接检测。
## 自动连续性验证
本地回归时可让模拟器自动启动通话、播放一段测试音频,并把双方最终字幕写入JSON结果文件。该入口只接受本机文件和回环地址,不影响正常交互使用:
```powershell
$env:SIMULATOR_AUTO_TEST = '1'
$env:SIMULATOR_AUTOMATED_AUDIO_PATH = 'C:\path\to\question.wav'
$env:SIMULATOR_AUTO_RESULT_PATH = 'C:\path\to\result.json'
& .\release\voice-device-simulator-0.1.0-windows-x64.exe
```text
release/hardware-agent-tester-0.3.5-windows-x64.exe
```
测试会等待长者与智能体的最终字幕,结束通话后退出;结果文件包含通话ID、最终话轮和通过/失败状态。测试音频支持WAV、MP3和OGG,大小不得超过10 MB。
当前测试包未配置商业代码签名证书,Windows 可能显示“未知发布者”。交付前可用同目录 SHA-256 文件校验完整性;正式面向终端用户分发时应由甲方或发布主体使用其代码签名证书签名。
`npm run build` 会依次执行协议单测、TypeScript 检查、双页面构建和 Windows x64 便携包封装。
## 本地完整自动回归
先启动仓库根目录的 Compose 开发环境,然后运行:
```powershell
Set-Location .\device-simulator-windows
pwsh -NoProfile -File .\test\run-hardware-e2e.ps1
pwsh -NoProfile -File .\test\run-hardware-e2e.ps1 -ModelMode s2s
pwsh -NoProfile -File .\test\run-hardware-e2e.ps1 -ExecutablePath .\release\hardware-agent-tester-0.3.5-windows-x64.exe
```
第一条验证通用 LLM 报文,第二条验证端到端语音报文。回归会临时创建 `low_load` 设备绑定,启动一个校验真实火山签名和事件格式的本机 WebSocket 网关,再启动实际 Electron 窗口。测试只有在以下证据全部成立时才返回 PASS:
- 动态上下文确认后才开放麦克风;
- PCM 上行和音频下行均发生;
- 用户与智能体各产生一条最终字幕并写入 PostgreSQL;
- 平台下发停止状态,Windows 程序通过下一次 `session_updated` 收到 `stop_session`;
- 会话、硬件会话均为 `COMPLETED`;
- SaaS `conversation.started` 和 `conversation.completed` 事件均已产生;
- 页面 13 项验收门禁全部通过。
测试结束会删除临时会话、事件和设备绑定,不污染日常开发数据。
## 安全边界
- RTC AppKey、火山AK/SK和豆包Access Token只存在本地后端的受保护文件中;
- 模拟器安装包只接收平台签发的短期RTC Token;
- 设备签名密钥只驻留Electron主进程内存,不写入浏览器存储;
- 当前测试构建只允许设备API地址指向`localhost`、`127.0.0.1`或固定测试服务器`https://121.41.46.238`,避免误连其他环境;
- 关闭软件或结束通话时停止音频采集并销毁RTC引擎。
- 未取得`record_near`或`record_far`授权的轨道不会注册PCM回调;`upload_allowed=false`时不会采集或上传任何原录音。
- Product Secret 只存在于平台KMS/Secret挂载和注册调用内存,不到达设备,不写入数据库明文、日志或报告。
- 平台返回的本设备Device Secret使用 Electron `safeStorage` 加密后保存在当前 Windows 用户目录,不进入源码和安装包;License由火山产品侧分配,不下发Windows程序。
- 平台设备密钥只驻留 Electron 主进程,渲染页面不能读取已加载的明文。
- 记忆工具地址必须与配置的平台 API 同源,防止短期令牌被发送给第三方地址。
- 默认只允许连接火山官方硬件网关;本机 `ws://127.0.0.1` 仅供自动回归。
- 平台确认 `session.updated` 前不会启动麦克风;收到 `stop_session` 后不会继续发送音频。
- 导出的报告包含设备标识、Bot、配置摘要、事件和字幕,但不包含 Product Secret、Device Secret、平台签名密钥或记忆工具令牌。
- 自动回归通过仅在`SIMULATOR_AUTO_TEST=1`时生效的进程环境注入mock一机一密,不写入正式Windows凭据缓存。
## 实时字幕前提
## 真实验收与本地模拟的区别
测试所用RTC AppID必须在火山RTC控制台开启“实时字幕”和“流式语音识别”。流式语音识别凭据来自豆包语音控制台创建的“流式语音识别”应用,包括APP ID、Access Token和Cluster ID;“音视频字幕生成”类型不能替代该应用。未开通时程序会显示`ServiceAccessDenied`并清理本次未激活通话。
### 0.3.5 播放速度修复
最终字幕会在程序的“实时字幕与上传”区域显示。每条最终字幕使用通话内稳定键和严格递增序号上传;网络失败时留在内存队列,并在心跳及结束通话前重试。存在未上传字幕时,程序不会结束并丢弃当前通话。
- 根据火山 low-load WebSocket SDK 示例,将智能体下行 PCM 的源采样率从错误的 24kHz 修正为 16kHz,避免 1.5 倍速和音调升高。
- Windows 输出设备不是 16kHz 时,由 AudioWorklet 按实际系统输出采样率连续重采样,不改变语速和音高。
完整本地启动顺序见[本地真实RTC联调说明](../deploy/local-rtc/README.md)。
### 0.3.4 连续音频播放修复
- 火山返回的 `pcm_s16le/16kHz/mono` 音频进入常驻 AudioWorklet 环形缓冲,不再按 WebSocket 分片创建独立播放器。
- 默认预缓冲 120ms,网络瞬时抖动时自动重新缓冲,减少断音、金属音和语速漂移。
- 连续处理跨分片的奇数字节,并在开始、结束和断流时做短淡入淡出以避免爆音。
- 测试报告新增播放分片数、采样数和欠载次数。
### 0.3.3 失败会话修复
- 火山 `error` 事件与连接 Promise 的异常共用一个失败清理任务,同一轮仅提交一次 `session_failed`,保留首次错误。
- 失败清理期间禁止发送文字或重复结束;延迟到达的事件不再恢复“通话中”。失败后重新检测即可开始新一轮。
- 新通话清空上一轮验收结果、错误和计时;失败清理的关闭不再标为正常关闭。
- `npm test` 包含执行实际渲染器代码的并发失败回归测试,不会连接线上平台或消耗火山额度。
- 此修复不代表火山 `internal_error: StartVoiceChat failed` 的上游原因已解决。
本地自动回归证明 Windows 程序、平台控制面、数据库、PCM 管道和协议状态机完整可执行。最终真实火山验收仍必须使用实际 Instance/Product/Device/Bot/License 凭据,确认火山返回真实 ASR、LLM、TTS 音频;程序不会把本机模拟网关的 PASS 冒充真实供应商验收。
@@ -0,0 +1,288 @@
const crypto = require("node:crypto");
const WebSocket = require("ws");
const DEFAULT_GATEWAY_URL = "wss://ai-gateway.vei.volces.com/v1/realtime";
const VOICE_COMPANION_RULES = [
"你在和一位长者进行真实语音对话。先接住对方刚说的内容,再决定是否追问。",
"通常回复一到两句并默认使用陈述句。任意连续三次回复最多出现一个问题;索取信息、确认或同意都算问题,不以问号为准,吧、吗、呢、是不是、有没有、会不会、对不对、可不可以、愿不愿意都算问题。",
"如果最近两次回复已经问过,或者长者只说是啊、嗯、对或不是,本轮必须只用陈述句回应并停下来,不能用吧请求确认,也不能换一种说法继续追问。",
"长者分享经历时,先回应一个由长者亲口说出的具体细节;需要追问时一次只问一个开放而具体的问题,避免审问式追问。",
"只能把长者明确说过的内容归到长者本人。不得补写现场、动作、人物、情绪、原因、结果或第三方态度;不得用肯定、一定、应该、想必、我猜、听得出、看得出包装猜测,不评价长者很厉害,不声称大家都佩服您。历史资料只在当前话题自然相关时使用,不要突然提起。",
"示例:长者说在工厂修机器,可以问哪一次维修印象最深;长者接着只说是啊,应答这门手艺里的经验都是一点点积累起来的,然后停下。不要说肯定很辛苦吧、您当时肯定很厉害、大家应该都很佩服您吧。",
"对方表示不想说、累了、忘了或转移话题时,尊重并换话题或结束。不要使用催促、埋怨或让对方有负担的开场白。",
"回复只包含直接说出口的口语正文,语速自然。不要输出或朗读动作、表情、语气、情绪、场景等舞台提示,不要使用括号或星号包裹表演说明。",
"不要复述人设、系统配置或对话标识。",
].join("");
function requiredText(value, name, maximum = 512) {
const normalized = String(value || "").trim();
if (!normalized || normalized.length > maximum) throw new Error(`${name}不能为空或过长`);
return normalized;
}
function validateDeviceName(value) {
const name = requiredText(value, "火山设备名称", 128);
if (!/^[a-zA-Z0-9_\-.:@]{1,128}$/.test(name)) throw new Error("火山设备名称格式不正确");
return name;
}
function hmacBase64(secret, content) {
return crypto.createHmac("sha256", secret).update(content).digest("base64");
}
function websocketSignature({ authType = 1, deviceName, randomNumber, productKey, timestamp, instanceID }, deviceSecret) {
const content = `auth_type=${authType}&device_name=${deviceName}&random_num=${randomNumber}&product_key=${productKey}&timestamp=${timestamp}&instance_id=${instanceID}`;
return hmacBase64(deviceSecret, content);
}
function buildSessionPrompt(sessionConfig) {
const sections = [
requiredText(sessionConfig.authorized_context, "授权上下文", 4000),
String(sessionConfig.system_prompt || "").trim(),
String(sessionConfig.persona_tone || "").trim() ? `表达风格(仅指导表达,不可逐字复述):${String(sessionConfig.persona_tone).trim()}` : "",
VOICE_COMPANION_RULES,
].filter(Boolean);
return sections.join("\n\n");
}
function buildSessionUpdate(sessionConfig, eventID, modelMode) {
const normalizedMode = String(modelMode || sessionConfig.model_mode || "llm").trim().toLowerCase();
if (!new Set(["llm", "s2s"]).has(normalizedMode)) throw new Error("智能体模型类型必须为 llm 或 s2s");
const dialogID = requiredText(sessionConfig.dialog_id, "对话标识", 200);
const systemPrompt = buildSessionPrompt(sessionConfig);
const config = {
ASRConfig: { TurnDetectionMode: 0 },
};
if (normalizedMode === "s2s") {
config.S2SConfig = {
ProviderParams: {
dialog: {
system_role: systemPrompt,
speaking_style: String(sessionConfig.persona_tone || "").trim(),
dialog_id: dialogID,
},
},
};
}
config.LLMConfig = { SystemMessages: [systemPrompt] };
const memory = sessionConfig.memory;
if (memory && typeof memory === "object") {
const collectionName = requiredText(memory.collection_name, "Viking 记忆库名称", 200);
const userID = requiredText(memory.user_id, "Viking 用户标识", 200);
const memoryTypes = Array.isArray(memory.memory_types)
? memory.memory_types.map((value) => requiredText(value, "Viking 记忆类型", 100))
: [];
if (!memoryTypes.length) throw new Error("Viking 记忆类型不能为空");
const filter = { user_id: [userID], memory_type: memoryTypes };
if (String(memory.assistant_id || "").trim()) {
filter.assistant_id = [requiredText(memory.assistant_id, "Viking 助手标识", 200)];
}
config.MemoryConfig = {
Enable: true,
Provider: "volc",
ProviderParams: {
collection_name: collectionName,
filter,
limit: Number.isInteger(memory.limit) && memory.limit > 0 ? Math.min(memory.limit, 5) : 5,
...(String(memory.transition_words || "").trim()
? { transition_words: String(memory.transition_words).trim() }
: {}),
},
};
}
return {
event_id: eventID,
type: "session.update",
session: {
object: "realtime.session",
model: "",
input_audio_format: "pcm16",
output_audio_format: "pcm16",
config,
agent_config: { EnableConversationStateCallback: false },
},
};
}
function normalizeProviderError(event) {
const code = String(event?.error?.code || event?.error?.type || "PROVIDER_ERROR");
const message = String(event?.error?.message || "火山硬件智能体返回错误");
return new Error(`${code}: ${message}`);
}
class HardwareAgentClient {
constructor(options = {}) {
this.WebSocketImpl = options.WebSocketImpl || WebSocket;
this.gatewayURL = options.gatewayURL || DEFAULT_GATEWAY_URL;
this.emit = typeof options.emit === "function" ? options.emit : () => {};
this.socket = undefined;
this.sessionCreated = undefined;
this.providerConnectionRef = "";
this.pendingCalls = new Map();
this.pingTimer = undefined;
this.connectionPromise = undefined;
this.sessionUpdateSent = false;
}
async connect(input) {
if (this.socket) throw new Error("火山硬件智能体连接已经存在");
const instanceID = requiredText(input.instanceID, "实例 ID", 200);
const productKey = requiredText(input.productKey, "Product Key", 200);
const deviceName = validateDeviceName(input.deviceName);
const deviceSecret = requiredText(input.deviceSecret, "Device Secret", 4096);
const hardwareID = requiredText(input.hardwareID, "Hardware ID", 200);
const botID = requiredText(input.botID, "Bot ID", 200);
const randomNumber = crypto.randomInt(1, 2_147_483_647);
const timestamp = Math.floor(Date.now() / 1000);
const signature = websocketSignature({ deviceName, randomNumber, productKey, timestamp, instanceID }, deviceSecret);
const target = new URL(this.gatewayURL);
target.searchParams.set("bot", botID);
target.searchParams.set("wait_for_session_update", "true");
const headers = {
"X-Auth-Type": "1",
"X-Product-Key": productKey,
"X-Device-Name": deviceName,
"X-Random-Num": String(randomNumber),
"X-Timestamp": String(timestamp),
"X-Instance-Id": instanceID,
"X-Signature": signature,
"X-Hardware-Id": hardwareID,
};
const socket = new this.WebSocketImpl(target.toString(), { headers, handshakeTimeout: 10_000, maxPayload: 8 * 1024 * 1024 });
this.socket = socket;
this.connectionPromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("等待火山 session.updated 超时")), 15_000);
const finish = (callback, value) => {
clearTimeout(timeout);
callback(value);
};
socket.once("open", () => {
this.emit({ type: "transport.open" });
// wait_for_session_update=true makes the gateway wait for the client
// configuration before it publishes the usable session. Sending only
// after session.created deadlocks until the provider-side timeout.
this.sendJSON(buildSessionUpdate(input.sessionConfig, `windows-session-update-${crypto.randomUUID()}`, input.modelMode));
this.sessionUpdateSent = true;
});
socket.on("message", (data) => {
let event;
try { event = JSON.parse(Buffer.isBuffer(data) ? data.toString("utf8") : String(data)); }
catch { this.emit({ type: "transport.invalid_message" }); return; }
this.handleProviderEvent(event, input.sessionConfig, input.modelMode);
// In the hardware gateway's wait_for_session_update mode, the initial
// session.created event is published only after the pre-creation
// session.update has been accepted. Some Bot versions do not emit an
// additional session.updated event for that initial configuration.
if (event.type === "session.updated" || (event.type === "session.created" && this.sessionUpdateSent)) {
const connectionRef = String(event?.session?.id || this.providerConnectionRef || event.event_id || "").trim();
if (!connectionRef) return finish(reject, new Error("火山会话确认未提供可关联的会话编号"));
this.providerConnectionRef = connectionRef;
finish(resolve, { providerConnectionRef: connectionRef, session: event.session || {} });
} else if (event.type === "error" && !this.providerConnectionRef) {
finish(reject, normalizeProviderError(event));
}
});
socket.once("unexpected-response", (_request, response) => {
finish(reject, new Error(`火山 WebSocket 鉴权失败:HTTP ${response.statusCode}`));
});
socket.once("error", (error) => {
this.emit({ type: "transport.error", message: error.message });
if (!this.providerConnectionRef) finish(reject, error);
});
socket.once("close", (code, reason) => {
this.stopPing();
this.socket = undefined;
this.emit({ type: "transport.close", code, reason: Buffer.from(reason || "").toString("utf8") });
if (!this.providerConnectionRef) finish(reject, new Error(`火山连接提前关闭:${code}`));
});
});
try {
const result = await this.connectionPromise;
this.startPing();
return result;
} catch (error) {
this.close(1011, "connection setup failed");
throw error;
}
}
handleProviderEvent(event, sessionConfig, modelMode) {
if (event.type === "session.created") {
this.sessionCreated = event.session || {};
this.providerConnectionRef = String(event?.session?.id || event.event_id || "").trim();
}
if (event.type === "conversation.item.created" && event?.item?.type === "function_call" && event.item.call_id) {
this.pendingCalls.set(String(event.item.call_id), String(event.item.name || ""));
}
this.emit(event);
}
sendAudio(bytes) {
const buffer = Buffer.from(bytes);
if (!buffer.length) return;
if (buffer.length > 64 * 1024) throw new Error("单个音频分片不能超过64KiB");
this.sendJSON({
event_id: `windows-audio-${crypto.randomUUID()}`,
type: "input_audio_buffer.append",
audio: buffer.toString("base64"),
});
}
sendText(text, interruptMode = 1) {
const normalized = requiredText(text, "文本问题", 2000);
this.sendJSON({
event_id: `windows-text-${crypto.randomUUID()}`,
type: "conversation.item.create",
item: { type: "message", role: "user", content: [{ type: "input_text", text: normalized }], interrupt_mode: interruptMode },
});
}
completeFunctionCall(callID, output) {
const normalizedCallID = requiredText(callID, "函数调用编号", 200);
this.sendJSON({
event_id: `windows-function-${crypto.randomUUID()}`,
type: "conversation.item.create",
item: { type: "function_call_output", call_id: normalizedCallID, output: JSON.stringify(output) },
});
this.sendJSON({ event_id: `windows-response-${crypto.randomUUID()}`, type: "response.create" });
this.pendingCalls.delete(normalizedCallID);
}
sendJSON(value) {
if (!this.socket || this.socket.readyState !== this.WebSocketImpl.OPEN) throw new Error("火山硬件智能体连接未就绪");
this.socket.send(JSON.stringify(value));
}
startPing() {
this.stopPing();
this.pingTimer = setInterval(() => {
try { if (this.socket?.readyState === this.WebSocketImpl.OPEN && typeof this.socket.ping === "function") this.socket.ping(); }
catch { /* close/error callbacks own recovery */ }
}, 20_000);
}
stopPing() {
if (this.pingTimer) clearInterval(this.pingTimer);
this.pingTimer = undefined;
}
close(code = 1000, reason = "device session ended") {
this.stopPing();
const socket = this.socket;
this.socket = undefined;
this.providerConnectionRef = "";
this.sessionUpdateSent = false;
this.pendingCalls.clear();
if (!socket) return;
try { socket.close(code, reason); } catch { try { socket.terminate?.(); } catch { /* no-op */ } }
}
}
module.exports = {
DEFAULT_GATEWAY_URL,
HardwareAgentClient,
buildSessionPrompt,
buildSessionUpdate,
websocketSignature,
};
@@ -0,0 +1,122 @@
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const test = require("node:test");
const {
HardwareAgentClient,
buildSessionUpdate,
websocketSignature,
} = require("./hardware-agent-client.cjs");
test("WebSocket signatures use the documented canonical field order", () => {
const connection = websocketSignature({ authType: 1, deviceName: "device-1", randomNumber: 42, productKey: "product-1", timestamp: 1700000000, instanceID: "instance-1" }, "device-secret");
assert.equal(connection, "6OFE1DUZzRgNy0o5IzZSmqKw3SUP4s3GxK2YHe8phfo=");
});
test("LLM session update uses the official Config/AgentConfig schema", () => {
const update = buildSessionUpdate({ dialog_id: "conversation-1", system_prompt: "你是陪伴助手", persona_tone: "亲切", authorized_context: "只可使用已授权背景" }, "event-1");
assert.equal(update.type, "session.update");
assert.equal(update.session.object, "realtime.session");
assert.equal(update.session.input_audio_format, "pcm16");
assert.equal(update.session.output_audio_format, "pcm16");
assert.equal(update.session.config.ASRConfig.TurnDetectionMode, 0);
assert.match(update.session.config.LLMConfig.SystemMessages[0], /只可使用已授权背景/);
assert.match(update.session.config.LLMConfig.SystemMessages[0], /你是陪伴助手/);
assert.match(update.session.config.LLMConfig.SystemMessages[0], /亲切/);
assert.match(update.session.config.LLMConfig.SystemMessages[0], /任意连续三次回复最多出现一个问题/);
assert.match(update.session.config.LLMConfig.SystemMessages[0], /简短附和或否定|长者只说是啊/);
assert.match(update.session.config.LLMConfig.SystemMessages[0], /第三方态度/);
assert.match(update.session.config.LLMConfig.SystemMessages[0], /大家应该都很佩服您吧/);
assert.match(update.session.config.LLMConfig.SystemMessages[0], /先回应一个由长者亲口说出的具体细节/);
assert.doesNotMatch(update.session.config.LLMConfig.SystemMessages[0], /本次对话标识/);
assert.doesNotMatch(update.session.config.LLMConfig.SystemMessages[0], /conversation-1/);
assert.equal(update.session.agent_config.EnableConversationStateCallback, false);
assert.equal("system_prompt" in update.session, false);
});
test("S2S session update maps persona and dialog id to provider dialog config", () => {
const update = buildSessionUpdate({ dialog_id: "conversation-2", system_prompt: "你是陪伴助手", persona_tone: "亲切自然", authorized_context: "只使用授权信息" }, "event-2", "s2s");
const dialog = update.session.config.S2SConfig.ProviderParams.dialog;
assert.equal(dialog.dialog_id, "conversation-2");
assert.equal(dialog.speaking_style, "亲切自然");
assert.match(dialog.system_role, /只使用授权信息/);
assert.match(dialog.system_role, /你是陪伴助手/);
assert.match(update.session.config.LLMConfig.SystemMessages[0], /你是陪伴助手/);
});
test("session update maps the bound user to native Viking memory without credentials", () => {
const update = buildSessionUpdate({
dialog_id: "conversation-memory", model_mode: "s2s", authorized_context: "authorized",
memory: {
collection_name: "memory", user_id: "u_subject_hash", assistant_id: "platform_companion_v1",
memory_types: ["event_v1", "profile_v1"], limit: 5, transition_words: "自然承接历史记忆",
},
}, "event-memory");
const memory = update.session.config.MemoryConfig;
assert.equal(memory.Enable, true);
assert.equal(memory.Provider, "volc");
assert.equal(memory.ProviderParams.collection_name, "memory");
assert.deepEqual(memory.ProviderParams.filter.user_id, ["u_subject_hash"]);
assert.deepEqual(memory.ProviderParams.filter.assistant_id, ["platform_companion_v1"]);
assert.deepEqual(memory.ProviderParams.filter.memory_type, ["event_v1", "profile_v1"]);
assert.equal("token" in memory.ProviderParams, false);
});
class FakeWebSocket extends EventEmitter {
static OPEN = 1;
constructor(url, options) {
super();
this.url = url;
this.options = options;
this.readyState = 0;
this.sent = [];
FakeWebSocket.last = this;
setImmediate(() => {
this.readyState = FakeWebSocket.OPEN;
this.emit("open");
this.emit("message", Buffer.from(JSON.stringify({ type: "session.created", event_id: "created-event", session: { id: "provider-session-1" } })));
});
}
send(body) {
const value = JSON.parse(body);
this.sent.push(value);
if (value.type === "session.update") {
setImmediate(() => this.emit("message", Buffer.from(JSON.stringify({ type: "session.updated", event_id: "updated-event", session: { id: "provider-session-1" } }))));
}
}
ping() {}
close(code, reason) {
this.readyState = 3;
this.emit("close", code, Buffer.from(reason));
}
}
test("hardware client waits for dynamic config acknowledgement before becoming ready", async () => {
const events = [];
const client = new HardwareAgentClient({ WebSocketImpl: FakeWebSocket, gatewayURL: "ws://127.0.0.1:19090/v1/realtime", emit: (event) => events.push(event) });
const connected = await client.connect({
instanceID: "instance-1", productKey: "product-1", deviceName: "device-1",
deviceSecret: "device-secret-with-at-least-32-characters", hardwareID: "windows-test", botID: "bot-1",
sessionConfig: { dialog_id: "conversation-1", authorized_context: "authorized" },
});
assert.equal(connected.providerConnectionRef, "provider-session-1");
assert.match(FakeWebSocket.last.url, /bot=bot-1/);
assert.equal(FakeWebSocket.last.options.headers["X-Product-Key"], "product-1");
assert.equal(FakeWebSocket.last.sent[0].type, "session.update");
client.sendAudio(Buffer.from([1, 2, 3, 4]));
client.sendText("你好");
assert.equal(FakeWebSocket.last.sent[1].type, "input_audio_buffer.append");
assert.equal(FakeWebSocket.last.sent[2].type, "conversation.item.create");
assert.ok(events.some((event) => event.type === "session.created"));
client.close();
});
test("hardware client sends session.update immediately when the WebSocket opens", async () => {
const client = new HardwareAgentClient({ WebSocketImpl: FakeWebSocket, gatewayURL: "ws://127.0.0.1:19090/v1/realtime" });
await client.connect({
instanceID: "instance-1", productKey: "product-1", deviceName: "device-1",
deviceSecret: "device-secret-with-at-least-32-characters", hardwareID: "windows-test", botID: "bot-1",
sessionConfig: { dialog_id: "conversation-1", authorized_context: "authorized" },
});
assert.equal(FakeWebSocket.last.sent.filter((event) => event.type === "session.update").length, 1);
client.close();
});
@@ -0,0 +1,153 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const ts = require("typescript");
// Execute the actual renderer with a small DOM/IPC fixture; no network or audio.
const source = fs.readFileSync(path.join(__dirname, "../src/hardware.ts"), "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
}).outputText;
function fixture(overrides = {}) {
const elements = new Map();
const element = () => ({
style: {}, value: "", textContent: "", disabled: false, children: [],
append(...items) { this.children.push(...items); },
prepend(...items) { this.children.unshift(...items); },
replaceChildren(...items) { this.children = items; },
querySelector() { return null; }, addEventListener() {},
});
const get = (id) => {
if (!elements.has(id)) elements.set(id, element());
return elements.get(id);
};
const events = [];
const closes = [];
const texts = [];
const created = {
conversation_id: "test-conversation", session: {},
runtime: { transport: "low_load", config_digest: "digest", session_nonce: "nonce", provider_session_ref: "ref", bot_id: "test-bot" },
session_config: { authorized_context: "test", prompt_bundle_version: "test-v1" },
};
const ipc = {
getRuntimeDefaults: async () => ({}), getHardwareRuntimeDefaults: async () => ({}),
onHardwareProviderEvent: () => () => {},
createHardwareAgentSession: async () => created,
closeHardwareAgent: async (input) => { closes.push(input); },
hardwareAgentEvent: async (input) => { events.push(input); return { status: "FAILED" }; },
sendHardwareAgentText: async (input) => { texts.push(input); },
...overrides,
};
const context = vm.createContext({
exports: {}, require: () => ({}), console, URL,
document: { querySelector: () => get("app"), getElementById: get, createElement: element },
window: { deviceSimulator: ipc, addEventListener() {}, clearInterval() {}, setTimeout, setInterval() {} },
});
vm.runInContext(compiled + `\nglobalThis.renderer = { state, controls, setPhase, setCheck, resetSessionState, failHardwareConversation, stopHardwareConversation, startHardwareConversation, handleProviderEvent, sendTextQuestion, buildReport };`, context);
return { r: context.renderer, events, closes, texts, created, get, ipc };
}
test("provider error plus connect rejection sends one failure, including late duplicates", async () => {
const f = fixture();
const r = f.r;
f.ipc.connectHardwareAgent = async () => {
void r.handleProviderEvent({ type: "error", error: { code: "internal_error", message: "StartVoiceChat failed" } });
throw new Error("wrapped connect rejection");
};
r.setPhase("ready", "ready");
await r.startHardwareConversation();
await r.failHardwareConversation("SECOND_FAILURE");
await r.stopHardwareConversation();
await r.handleProviderEvent({ type: "error", error: { message: "late error" } });
assert.equal(f.events.length, 1);
assert.equal(f.events[0].eventType, "session_failed");
assert.equal(f.events[0].errorCode, "VOLC_HARDWARE_AGENT_ERROR");
assert.equal(f.closes.length, 1);
assert.equal(r.state.failure, "internal_error: StartVoiceChat failed");
assert.equal(r.state.phase, "error");
assert.equal(r.controls.stop.disabled, true);
assert.equal(r.controls.check.disabled, false);
});
test("concurrent failure callers share pending cleanup; late events cannot reactivate", async () => {
let release;
const gate = new Promise((resolve) => { release = resolve; });
const f = fixture({ closeHardwareAgent: () => gate });
f.r.state.platformSession = f.created;
f.r.state.failure = "first cause";
f.r.setPhase("active", "active");
const first = f.r.failHardwareConversation("FIRST");
const second = f.r.failHardwareConversation("SECOND");
assert.equal(first, second);
assert.equal(f.r.state.phase, "stopping");
await f.r.handleProviderEvent({ type: "response.done" });
assert.equal(f.r.state.phase, "stopping");
release();
await first;
assert.equal(f.events.length, 1);
assert.equal(f.events[0].errorCode, "FIRST");
await f.r.handleProviderEvent({ type: "transport.close", code: 1011 });
assert.equal(f.r.state.checks.get("provider_closed").status, "failed");
f.r.controls.textQuestion.value = "should not send";
await f.r.sendTextQuestion();
assert.equal(f.texts.length, 0);
});
test("new call clears stale checks, duration and failure while retaining device preparation", async () => {
const f = fixture();
f.r.state.platformSession = f.created;
f.r.state.failure = "old failure";
await f.r.failHardwareConversation("OLD");
for (const id of f.r.state.checks.keys()) f.r.setCheck(id, "passed", "old");
f.get("duration").textContent = "48:15";
f.r.resetSessionState();
assert.equal(f.r.state.failure, undefined);
assert.equal(f.r.state.failureTask, undefined);
assert.equal(f.get("duration").textContent, "00:00");
assert.equal(f.r.state.checks.get("platform_authenticated").status, "passed");
assert.equal(f.r.state.checks.get("credential_resolved").status, "passed");
assert.equal(f.r.state.checks.get("platform_completed").status, "pending");
f.r.state.platformSession = { ...f.created, conversation_id: "next-conversation" };
await f.r.failHardwareConversation("NEW");
assert.equal(f.events.length, 2);
assert.equal(f.events[1].conversationID, "next-conversation");
assert.equal(f.events[1].sequenceNo, 1);
});
test("failed platform receipt does not trigger a new terminal event on hangup", async () => {
let attempts = 0;
const f = fixture({ hardwareAgentEvent: async () => { attempts += 1; throw new Error("network unavailable"); } });
f.r.state.platformSession = f.created;
await f.r.failHardwareConversation("FIRST");
await f.r.stopHardwareConversation();
assert.equal(attempts, 1);
assert.equal(f.r.state.phase, "error");
assert.equal(f.r.buildReport().checks.find((c) => c.id === "platform_completed").status, "failed");
});
test("connection rejection without a provider event still reports failure once", async () => {
const f = fixture({ connectHardwareAgent: async () => { throw new Error("connect timeout"); } });
f.r.setPhase("ready", "ready");
await f.r.startHardwareConversation();
assert.equal(f.events.length, 1);
assert.equal(f.events[0].errorCode, "WINDOWS_HARDWARE_START_FAILED");
assert.match(f.r.state.failure, /connect timeout/);
assert.equal(f.r.state.phase, "error");
});
test("normal hangup still reports completed and closes normally", async () => {
const f = fixture();
f.ipc.hardwareAgentEvent = async (input) => { f.events.push(input); return { status: "COMPLETED" }; };
f.r.state.platformSession = f.created;
f.r.setPhase("active", "active");
await f.r.stopHardwareConversation();
assert.equal(f.events.length, 1);
assert.equal(f.events[0].eventType, "session_ended");
assert.equal(f.closes[0].code, 1000);
assert.equal(f.r.state.phase, "completed");
assert.equal(f.r.state.failureTask, undefined);
assert.equal(f.r.controls.start.disabled, false);
});
+367 -13
View File
@@ -1,14 +1,24 @@
const { app, BrowserWindow, ipcMain, session } = require("electron");
const { app, BrowserWindow, dialog, ipcMain, safeStorage, session } = require("electron");
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const tls = require("node:tls");
const { HardwareAgentClient } = require("./hardware-agent-client.cjs");
app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required");
const automatedUI = process.env.SIMULATOR_AUTO_TEST === "1" || process.env.SIMULATOR_REAL_E2E === "1";
if (automatedUI) {
app.commandLine.appendSwitch("use-fake-device-for-media-stream");
app.commandLine.appendSwitch("use-fake-ui-for-media-stream");
}
app.disableHardwareAcceleration();
let deviceConfig = null;
let desiredConfigRevision = 1;
let mainWindow = null;
let hardwareConfig = null;
let hardwareClient = null;
let activeHardwarePlatformSession = null;
function createWindow() {
const window = new BrowserWindow({
@@ -26,6 +36,10 @@ function createWindow() {
sandbox: true,
},
});
mainWindow = window;
window.once("closed", () => {
if (mainWindow === window) mainWindow = null;
});
const devArgument = process.argv.find((argument) => argument.startsWith("--dev-server-url="));
const devServerURL = devArgument ? devArgument.slice("--dev-server-url=".length) : "";
@@ -47,7 +61,7 @@ function createWindow() {
} catch (error) {
console.error("renderer diagnostics failed", error);
}
if (process.env.SIMULATOR_AUTO_TEST === "1") {
if (automatedUI) {
void runAutomatedSmoke(window);
}
});
@@ -62,9 +76,15 @@ function createWindow() {
if (screenshotPath) {
window.webContents.once("did-finish-load", () => {
setTimeout(async () => {
const image = await window.webContents.capturePage();
fs.writeFileSync(screenshotPath, image.toPNG());
app.quit();
try {
const image = await window.webContents.capturePage();
fs.writeFileSync(screenshotPath, image.toPNG());
} catch (error) {
console.error("simulator screenshot failed", error);
process.exitCode = 1;
} finally {
app.quit();
}
}, 1200);
});
}
@@ -95,24 +115,54 @@ async function runAutomatedSmoke(window) {
async function collectAutomatedConversationResult(window) {
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const deadline = Date.now() + 120_000;
let realQuestionSent = false;
let snapshot = {};
while (Date.now() < deadline) {
await delay(1000);
const encoded = await window.webContents.executeJavaScript(`JSON.stringify({
phase: document.querySelector('#phaseBadge')?.textContent || '',
conversation_id: document.querySelector('#conversationID')?.textContent || '',
model_mode: document.querySelector('#modelMode')?.value || '',
subject: [...document.querySelectorAll('.transcript-turn--subject span')].map((item) => item.textContent || ''),
agent: [...document.querySelectorAll('.transcript-turn--agent span')].map((item) => item.textContent || ''),
turns: [...document.querySelectorAll('.transcript-turn')].map((item) => ({speaker: item.classList.contains('transcript-turn--subject') ? 'subject' : 'agent', text: item.querySelector('span')?.textContent || ''})),
events: [...document.querySelectorAll('#eventLog li span')].map((item) => item.textContent || '')
events: [...document.querySelectorAll('#eventLog li span')].map((item) => item.textContent || ''),
checks: [...document.querySelectorAll('#flowChecks .flow-check')].map((item) => ({label: item.querySelector('span')?.textContent || '', detail: item.querySelector('small')?.textContent || '', status: item.classList.contains('flow-check--passed') ? 'passed' : item.classList.contains('flow-check--failed') ? 'failed' : 'pending'}))
})`);
snapshot = JSON.parse(encoded);
console.log(`[auto-test] conversation ${encoded}`);
if (process.env.SIMULATOR_REAL_E2E === "1" && snapshot.phase === "通话中" && !realQuestionSent) {
const question = String(process.env.SIMULATOR_REAL_E2E_QUESTION || "请用一句简短的话介绍你自己。 ").trim();
await window.webContents.executeJavaScript(`(() => { const input = document.querySelector('#textQuestion'); if (input) input.value = ${JSON.stringify(question)}; document.querySelector('#sendTextButton')?.click(); })()`);
realQuestionSent = true;
console.log("[real-e2e] text question sent");
}
const subjectIndex = snapshot.turns?.findIndex((turn) => turn.speaker === "subject") ?? -1;
const hasAgentReply = subjectIndex >= 0 && snapshot.turns.slice(subjectIndex + 1).some((turn) => turn.speaker === "agent");
if (hasAgentReply) {
await window.webContents.executeJavaScript(`document.querySelector('#stopButton')?.click()`);
snapshot.result = "PASS";
const hardwarePage = await window.webContents.executeJavaScript(`Boolean(document.querySelector('#flowChecks'))`);
const waitForPlatformStop = hardwarePage && process.env.SIMULATOR_AUTO_WAIT_FOR_PLATFORM_STOP === "1";
if (!waitForPlatformStop) await window.webContents.executeJavaScript(`document.querySelector('#stopButton')?.click()`);
if (hardwarePage) {
const stopDeadline = Date.now() + 30_000;
while (Date.now() < stopDeadline) {
await delay(500);
const phase = await window.webContents.executeJavaScript(`document.querySelector('#phaseBadge')?.textContent || ''`);
if (phase === "已完成" || phase === "异常") break;
}
const finalEncoded = await window.webContents.executeJavaScript(`JSON.stringify({
phase: document.querySelector('#phaseBadge')?.textContent || '',
conversation_id: document.querySelector('#conversationID')?.textContent || '',
model_mode: document.querySelector('#modelMode')?.value || '',
turns: [...document.querySelectorAll('.transcript-turn')].map((item) => ({speaker: item.classList.contains('transcript-turn--subject') ? 'subject' : 'agent', text: item.querySelector('span')?.textContent || ''})),
events: [...document.querySelectorAll('#eventLog li span')].map((item) => item.textContent || ''),
checks: [...document.querySelectorAll('#flowChecks .flow-check')].map((item) => ({label: item.querySelector('span')?.textContent || '', detail: item.querySelector('small')?.textContent || '', status: item.classList.contains('flow-check--passed') ? 'passed' : item.classList.contains('flow-check--failed') ? 'failed' : 'pending'}))
})`);
snapshot = JSON.parse(finalEncoded);
snapshot.result = snapshot.phase === "已完成" ? "PASS" : "ERROR";
} else {
snapshot.result = "PASS";
}
break;
}
if (snapshot.phase === "异常") {
@@ -160,7 +210,10 @@ function loadDefaultDeviceSecret() {
const localAppData = String(process.env.LOCALAPPDATA || "").trim();
const userProfile = String(process.env.USERPROFILE || "").trim();
const portableDirectory = String(process.env.PORTABLE_EXECUTABLE_DIR || "").trim();
if (localAppData) candidates.push(path.join(localAppData, "DataTerminalVoiceDevice", "server-device-secret"));
if (localAppData) {
candidates.push(path.join(localAppData, "DataTerminalHardwareAgent", "server-device-secret"));
candidates.push(path.join(localAppData, "DataTerminalVoiceDevice", "server-device-secret"));
}
if (userProfile) candidates.push(path.join(userProfile, "AppData", "Local", "DataTerminalVoiceDevice", "server-device-secret"));
try {
const home = app.getPath("home");
@@ -182,11 +235,134 @@ function loadDefaultDeviceSecret() {
ipcMain.handle("sim:get-runtime-defaults", () => ({
apiURL: process.env.SIMULATOR_DEFAULT_API_URL || "https://121.41.46.238",
deviceID: process.env.SIMULATOR_DEFAULT_DEVICE_ID || "10000000-0000-0000-0000-000000000511",
deviceID: process.env.SIMULATOR_DEFAULT_DEVICE_ID || "10000000-0000-0000-0000-000000000512",
deviceSecret: "",
deviceSecretConfigured: loadDefaultDeviceSecret().length >= 32,
}));
function hardwareCredentialPath() {
return path.join(app.getPath("userData"), "volc-hardware-device-credential.json");
}
function readSavedHardwareMetadata() {
try {
const stored = JSON.parse(fs.readFileSync(hardwareCredentialPath(), "utf8"));
if (!stored.instanceID || !stored.productKey || !stored.deviceName || !stored.encryptedDeviceSecret) return undefined;
return stored;
} catch { return undefined; }
}
function loadSavedHardwareCredential(identity) {
if (!safeStorage.isEncryptionAvailable()) return undefined;
try {
const stored = JSON.parse(fs.readFileSync(hardwareCredentialPath(), "utf8"));
if (stored.schemaVersion !== 3 || stored.instanceID !== identity.instanceID || stored.productKey !== identity.productKey || stored.deviceName !== identity.deviceName) return undefined;
const secret = safeStorage.decryptString(Buffer.from(stored.encryptedDeviceSecret, "base64"));
if (secret.length < 16) return undefined;
return { deviceSecret: secret, hardwareID: String(stored.hardwareID || identity.hardwareID || "") };
} catch { return undefined; }
}
function saveHardwareCredential(identity, deviceSecret, metadata = {}) {
if (!safeStorage.isEncryptionAvailable()) return false;
const target = hardwareCredentialPath();
fs.mkdirSync(path.dirname(target), { recursive: true });
const payload = {
schemaVersion: 3,
instanceID: identity.instanceID,
productKey: identity.productKey,
deviceName: identity.deviceName,
hardwareID: identity.hardwareID,
platformDeviceID: String(deviceConfig?.deviceID || ""),
credentialRef: String(metadata.credentialRef || ""),
credentialVersion: Number(metadata.credentialVersion || 1),
encryptedDeviceSecret: safeStorage.encryptString(deviceSecret).toString("base64"),
updatedAt: new Date().toISOString(),
};
const temporary = `${target}.${process.pid}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
fs.renameSync(temporary, target);
return true;
}
function hardwareIdentity(input) {
const instanceID = String(input?.instanceID || "").trim();
const productKey = String(input?.productKey || "").trim();
const deviceName = String(input?.deviceName || "").trim();
const hardwareID = String(input?.hardwareID || "").trim();
if (!instanceID || instanceID.length > 200) throw new Error("实例 ID 不能为空或过长");
if (!productKey || productKey.length > 200) throw new Error("Product Key 不能为空或过长");
if (!/^[a-zA-Z0-9_\-.:@]{1,128}$/.test(deviceName)) throw new Error("火山设备名称格式不正确");
if (!hardwareID || hardwareID.length > 200) throw new Error("Hardware ID 不能为空或过长");
return { instanceID, productKey, deviceName, hardwareID };
}
ipcMain.handle("sim:get-hardware-runtime-defaults", () => {
const candidateMetadata = readSavedHardwareMetadata();
const legacyMock = candidateMetadata && [candidateMetadata.instanceID, candidateMetadata.productKey, candidateMetadata.deviceName, candidateMetadata.hardwareID]
.some((value) => String(value || "").toLowerCase().includes("mock") || String(value || "").toLowerCase().includes("e2e"));
const savedMetadata = automatedUI || !legacyMock ? candidateMetadata : undefined;
const instanceID = String(process.env.SIMULATOR_VOLC_INSTANCE_ID || savedMetadata?.instanceID || "").trim();
const productKey = String(process.env.SIMULATOR_VOLC_PRODUCT_KEY || savedMetadata?.productKey || "").trim();
const deviceName = String(process.env.SIMULATOR_VOLC_DEVICE_NAME || savedMetadata?.deviceName || "windows-hardware-agent-test").trim();
const hardwareID = String(process.env.SIMULATOR_VOLC_HARDWARE_ID || savedMetadata?.hardwareID || `windows-${crypto.createHash("sha256").update(app.getPath("userData")).digest("hex").slice(0, 24)}`).trim();
const modelMode = String(process.env.SIMULATOR_VOLC_MODEL_MODE || "llm").trim().toLowerCase() === "s2s" ? "s2s" : "llm";
const saved = instanceID && productKey ? loadSavedHardwareCredential({ instanceID, productKey, deviceName, hardwareID }) : undefined;
return { instanceID, productKey, deviceName, hardwareID, modelMode, deviceCredentialConfigured: Boolean(saved) };
});
ipcMain.handle("sim:configure-hardware", async (_event, input) => {
if (!deviceConfig) throw new Error("请先连接平台");
const requestedHardwareID = String(input?.hardwareID || "").trim();
if (!requestedHardwareID || requestedHardwareID.length > 200) throw new Error("Hardware ID 不能为空或过长");
const automatedTest = process.env.SIMULATOR_AUTO_TEST === "1";
let provisioned;
if (automatedTest) {
provisioned = {
instance_id: String(process.env.SIMULATOR_VOLC_INSTANCE_ID || "").trim(),
product_key: String(process.env.SIMULATOR_VOLC_PRODUCT_KEY || "").trim(),
device_name: String(process.env.SIMULATOR_VOLC_DEVICE_NAME || "").trim(),
hardware_id: String(process.env.SIMULATOR_VOLC_HARDWARE_ID || requestedHardwareID).trim(),
device_secret: String(process.env.SIMULATOR_VOLC_DEVICE_SECRET || "").trim(),
credential_source: "isolated_test_override",
credential_version: 1,
credential_ref: "test-only",
};
} else {
provisioned = await deviceRequest("POST", "/device/v3/hardware-agent-credentials:provision", {
hardware_id: requestedHardwareID,
}, `windows-hardware-provision-${nonce()}`);
}
const identity = hardwareIdentity({
instanceID: provisioned?.instance_id,
productKey: provisioned?.product_key,
deviceName: provisioned?.device_name,
hardwareID: provisioned?.hardware_id,
});
const deviceSecret = String(provisioned?.device_secret || "").trim();
if (deviceSecret.length < 16 || deviceSecret.length > 4096) throw new Error("Device Secret 长度不正确");
const persisted = automatedTest ? false : saveHardwareCredential(identity, deviceSecret, {
credentialRef: provisioned?.credential_ref,
credentialVersion: provisioned?.credential_version,
});
if (!automatedTest && !persisted) throw new Error("Windows 安全存储不可用,拒绝保存 Device Secret");
hardwareConfig = { ...identity, deviceSecret };
// 首次预配会由平台发布产品级默认 Bot/传输配置,并可能推进配置版本。
// 设备必须重新拉取版本,后续创建会话时不能继续上报预配前的旧 revision。
if (!automatedTest) {
const configuration = await deviceRequest("GET", "/device/v2/config");
desiredConfigRevision = Number(configuration?.revision || desiredConfigRevision || 1);
}
return {
...identity,
registered: String(provisioned?.credential_source || "").includes("dynamic_registration"),
persisted,
credentialSource: String(provisioned?.credential_source || "platform_provisioning"),
credentialVersion: Number(provisioned?.credential_version || 1),
configRevision: desiredConfigRevision,
};
});
function nonce() {
return crypto.randomBytes(16).toString("hex");
}
@@ -235,7 +411,7 @@ ipcMain.handle("sim:configure", (_event, input) => configure(input));
ipcMain.handle("sim:probe", async () => {
const query = new URLSearchParams({
firmware_version: "0.3.0-windows-rtc",
firmware_version: "0.3.2-windows-hardware-agent",
board_rev: "windows-x64",
rtc_sdk_version: "web-4.69.0-electron",
codec: "opus",
@@ -280,6 +456,14 @@ ipcMain.handle("sim:probe-rtc-network", async () => {
return { endpoints };
});
ipcMain.handle("sim:probe-hardware-network", async () => {
const endpoints = await Promise.all([
probeTLSEndpoint("ai-gateway.vei.volces.com"),
probeTLSEndpoint("iot-cn-shanghai.iot.volces.com"),
]);
return { endpoints };
});
ipcMain.handle("sim:get-automation-audio", async () => {
const configuredPath = String(process.env.SIMULATOR_AUTOMATED_AUDIO_PATH || "").trim();
if (!configuredPath) return undefined;
@@ -309,6 +493,173 @@ ipcMain.handle("sim:create-conversation", async () => deviceRequest("POST", "/de
requested_goal: "open_conversation",
}, `windows-create-${nonce()}`));
ipcMain.handle("sim:create-hardware-agent-session", async () => deviceRequest("POST", "/device/v3/agent-sessions", {
device_time: new Date().toISOString(),
monotonic_ms: Math.max(0, Math.round(performance.now())),
capabilities: {
hardware_agent: true,
sdk_version: "1.1.0-windows-standard-protocol",
transports: ["low_load"],
input_audio_format: "pcm_s16le_16000_mono",
output_audio_format: "pcm_s16le_16000_mono",
server_event_callback: false,
transcript_upload: true,
dynamic_session_config: true,
credential_rotation: true,
near_pcm_recording: false,
far_pcm_recording: false,
},
network: { type: "windows-local" },
requested_goal: "open_conversation",
config_revision: desiredConfigRevision,
}, `windows-hardware-create-${nonce()}`));
function emitHardwareEvent(event) {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send("sim:hardware-provider-event", event);
}
function allowedHardwareGatewayURL() {
const configured = String(process.env.SIMULATOR_HARDWARE_GATEWAY_URL || "wss://ai-gateway.vei.volces.com/v1/realtime").trim();
const parsed = new URL(configured);
const official = parsed.protocol === "wss:" && parsed.hostname === "ai-gateway.vei.volces.com";
const local = (parsed.protocol === "ws:" || parsed.protocol === "wss:") && ["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname);
if (!official && !local) throw new Error("硬件智能体网关只允许火山官方地址或本机测试地址");
return parsed.toString();
}
async function invokeHardwareMemoryTool(event) {
const client = hardwareClient;
const platformSession = activeHardwarePlatformSession;
if (!client || !platformSession) return;
const callID = String(event.call_id || "");
const functionName = String(client.pendingCalls.get(callID) || "").toLowerCase();
if (!["memory_search", "search_memory", "retrieve_memory"].includes(functionName)) return;
let argumentsValue = {};
try { argumentsValue = JSON.parse(String(event.arguments || "{}")); } catch { /* invalid input is handled below */ }
const query = String(argumentsValue.query || argumentsValue.keyword || argumentsValue.text || "").trim();
const limit = Math.max(1, Math.min(5, Number(argumentsValue.limit || 3)));
try {
if (!query) throw new Error("记忆工具缺少 query 参数");
const baseURL = new URL(deviceConfig.apiURL);
const target = new URL(platformSession.session_config.tool_url, `${baseURL.origin}/`);
if (target.origin !== baseURL.origin) throw new Error("记忆工具地址必须与平台同源");
const response = await fetch(target, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${platformSession.session_config.tool_scope_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, limit }),
});
const raw = await response.text();
let body;
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
if (!response.ok) throw new Error(body?.error?.message || body?.message || `HTTP ${response.status}`);
const result = body?.data ?? body;
client.completeFunctionCall(callID, result);
emitHardwareEvent({ type: "platform.memory_search.completed", call_id: callID, query, result_count: result?.items?.length || 0, degraded: Boolean(result?.degraded) });
} catch (error) {
client.completeFunctionCall(callID, { items: [], degraded: true, error: error.message });
emitHardwareEvent({ type: "platform.memory_search.failed", call_id: callID, message: error.message });
}
}
ipcMain.handle("sim:connect-hardware-agent", async (_event, input) => {
if (!deviceConfig) throw new Error("请先连接平台");
if (!hardwareConfig) throw new Error("请先完成火山设备鉴权配置");
if (hardwareClient) throw new Error("已有硬件智能体连接,请先结束");
const platformSession = input?.platformSession;
if (!platformSession?.runtime?.bot_id || !platformSession?.session_config?.dialog_id) throw new Error("平台硬件会话配置不完整");
if (platformSession.runtime.transport !== "low_load") throw new Error("Windows 标准协议测试程序只接受设备绑定为 low_load 的会话");
const modelMode = String(input?.modelMode || "llm").trim().toLowerCase();
if (!new Set(["llm", "s2s"]).has(modelMode)) throw new Error("智能体模型类型必须为 llm 或 s2s");
activeHardwarePlatformSession = platformSession;
const client = new HardwareAgentClient({
gatewayURL: allowedHardwareGatewayURL(),
emit: (event) => {
emitHardwareEvent(event);
if (event.type === "response.function_call_arguments.done") void invokeHardwareMemoryTool(event);
},
});
hardwareClient = client;
try {
return await client.connect({
...hardwareConfig,
botID: platformSession.runtime.bot_id,
sessionConfig: platformSession.session_config,
modelMode,
});
} catch (error) {
hardwareClient = null;
activeHardwarePlatformSession = null;
throw error;
}
});
ipcMain.on("sim:hardware-agent-audio", (_event, bytes) => {
try { hardwareClient?.sendAudio(Buffer.from(bytes)); }
catch (error) { emitHardwareEvent({ type: "transport.audio_error", message: error.message }); }
});
ipcMain.handle("sim:hardware-agent-text", (_event, input) => {
if (!hardwareClient) throw new Error("火山硬件智能体连接未就绪");
hardwareClient.sendText(input?.text, Number(input?.interruptMode || 1));
return { accepted: true };
});
ipcMain.handle("sim:close-hardware-agent", (_event, input) => {
const client = hardwareClient;
hardwareClient = null;
activeHardwarePlatformSession = null;
client?.close(Number(input?.code || 1000), String(input?.reason || "device session ended").slice(0, 100));
return { closed: true };
});
ipcMain.handle("sim:save-hardware-report", async (_event, report) => {
const result = await dialog.showSaveDialog(mainWindow, {
title: "导出硬件对话智能体测试报告",
defaultPath: `hardware-agent-test-${new Date().toISOString().replace(/[:.]/g, "-")}.json`,
filters: [{ name: "JSON 测试报告", extensions: ["json"] }],
});
if (result.canceled || !result.filePath) return { saved: false };
const redact = (value) => {
if (Array.isArray(value)) return value.map(redact);
if (!value || typeof value !== "object") return value;
const output = {};
for (const [key, item] of Object.entries(value)) {
if (/secret|signature|authorization|tool_scope_token|systemmessages|system_role|system_prompt|authorized_context|speaking_style|instructions/i.test(key)) output[key] = "[REDACTED]";
else output[key] = redact(item);
}
return output;
};
const safeReport = redact(JSON.parse(JSON.stringify(report)));
fs.writeFileSync(result.filePath, `${JSON.stringify(safeReport, null, 2)}\n`, "utf8");
return { saved: true, path: result.filePath };
});
ipcMain.handle("sim:hardware-agent-event", async (_event, input) => deviceRequest(
"POST",
`/device/v3/agent-sessions/${encodeURIComponent(input.conversationID)}/events`,
{
event_id: input.eventID || `windows-hardware-event-${nonce()}`,
sequence_no: Number(input.sequenceNo),
event_type: input.eventType,
occurred_at: new Date().toISOString(),
provider_session_ref: input.providerSessionRef,
session_nonce: input.sessionNonce,
...(input.eventType === "session_started" ? {
provider_connection_ref: input.providerConnectionRef,
bot_id: input.botID,
transport: input.transport,
config_digest: input.configDigest,
dynamic_config_applied: input.dynamicConfigApplied,
} : {}),
...(input.errorCode ? { error_code: input.errorCode } : {}),
},
));
ipcMain.handle("sim:activate-conversation", async (_event, input) => deviceRequest(
"POST",
`/device/v2/conversations/${encodeURIComponent(input.conversationID)}/activate`,
@@ -338,7 +689,7 @@ ipcMain.handle("sim:heartbeat", async (_event, input) => deviceRequest(
conversation_status: "ACTIVE",
rtc: { joined: true, uplink: true, downlink: true },
network: { type: "windows-local" },
firmware_version: "0.3.0-windows-rtc",
firmware_version: "0.3.2-windows-hardware-agent",
reported_config_revision: desiredConfigRevision,
rtc_credential_expires_at: input.credentialExpiresAt,
recording_queue: { pending_segments: 0, failed_segments: 0 },
@@ -356,7 +707,7 @@ ipcMain.handle("sim:submit-transcript-batch", async (_event, input) => {
const digest = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 32);
return deviceRequest(
"POST",
`/device/v2/conversations/${encodeURIComponent(conversationID)}/turns:batch`,
`/${input?.hardwareAgent ? "device/v3/agent-sessions" : "device/v2/conversations"}/${encodeURIComponent(conversationID)}/turns:batch`,
payload,
`windows-turns-${digest}`,
);
@@ -433,6 +784,9 @@ app.whenReady().then(() => {
});
app.on("window-all-closed", () => {
hardwareClient?.close(1001, "application closed");
hardwareClient = null;
activeHardwarePlatformSession = null;
if (process.platform !== "darwin") app.quit();
});
@@ -0,0 +1,76 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.join(__dirname, "../public/pcm-playback.worklet.js"), "utf8");
function fixture({ outputRate = 48000, prebufferMs = 120 } = {}) {
const messages = [];
let Processor;
class AudioWorkletProcessor {
constructor() {
this.port = { onmessage: null, postMessage: (message) => messages.push(message) };
}
}
const context = vm.createContext({
AudioWorkletProcessor,
Float32Array,
ArrayBuffer,
Math,
sampleRate: outputRate,
registerProcessor: (name, implementation) => {
assert.equal(name, "pcm16-playback");
Processor = implementation;
},
});
vm.runInContext(source, context);
const processor = new Processor({ processorOptions: { sourceSampleRate: 16000, prebufferMs } });
const push = (values) => {
const samples = new Float32Array(values);
processor.port.onmessage({ data: { type: "push", samples: samples.buffer } });
};
const process = () => {
const output = new Float32Array(128);
assert.equal(processor.process([], [[output]]), true);
return output;
};
return { messages, processor, push, process };
}
test("waits for prebuffer then resamples continuous 16k PCM without fragment gaps", () => {
const f = fixture({ outputRate: 48000, prebufferMs: 10 });
f.push(new Array(120).fill(0.25));
assert.ok(f.process().every((sample) => sample === 0), "must remain silent below the prebuffer threshold");
f.push(new Array(400).fill(0.25));
const first = f.process();
const second = f.process();
assert.ok(first.some((sample) => sample > 0.05), "buffered playback should start with a short anti-pop fade");
assert.ok(second.every((sample) => sample > 0.12), "playback should remain continuous across source fragments");
assert.equal(f.messages.filter((message) => message.type === "underflow").length, 0);
});
test("plays 16k source at real-time duration instead of the former 1.5x speed", () => {
const f = fixture({ outputRate: 48000, prebufferMs: 1 });
f.push(new Array(1000).fill(0.25));
f.process();
assert.ok(Math.abs(f.processor.readPosition - 128 / 3) < 0.001, `consumed ${f.processor.readPosition} source samples`);
});
test("reports network starvation and re-enters prebuffering instead of scheduling tiny buffers", () => {
const f = fixture({ outputRate: 48000, prebufferMs: 0.01 });
f.push(new Array(8).fill(0.5));
f.process();
assert.equal(f.messages.filter((message) => message.type === "underflow").length, 1);
assert.ok(f.process().every((sample) => sample === 0), "starved playback should wait for a new prebuffer");
});
test("drains the final response cleanly and emits a completion signal", () => {
const f = fixture({ outputRate: 24000 });
f.push(new Array(64).fill(-0.25));
f.processor.port.onmessage({ data: { type: "end" } });
f.process();
assert.equal(f.messages.filter((message) => message.type === "drained").length, 1);
assert.equal(f.messages.filter((message) => message.type === "underflow").length, 0);
});
@@ -5,8 +5,23 @@ contextBridge.exposeInMainWorld("deviceSimulator", {
configure: (input) => ipcRenderer.invoke("sim:configure", input),
probe: () => ipcRenderer.invoke("sim:probe"),
probeRTCNetwork: () => ipcRenderer.invoke("sim:probe-rtc-network"),
probeHardwareNetwork: () => ipcRenderer.invoke("sim:probe-hardware-network"),
getHardwareRuntimeDefaults: () => ipcRenderer.invoke("sim:get-hardware-runtime-defaults"),
configureHardware: (input) => ipcRenderer.invoke("sim:configure-hardware", input),
getAutomationAudio: () => ipcRenderer.invoke("sim:get-automation-audio"),
createConversation: () => ipcRenderer.invoke("sim:create-conversation"),
createHardwareAgentSession: () => ipcRenderer.invoke("sim:create-hardware-agent-session"),
connectHardwareAgent: (input) => ipcRenderer.invoke("sim:connect-hardware-agent", input),
sendHardwareAgentAudio: (bytes) => ipcRenderer.send("sim:hardware-agent-audio", bytes),
sendHardwareAgentText: (input) => ipcRenderer.invoke("sim:hardware-agent-text", input),
closeHardwareAgent: (input) => ipcRenderer.invoke("sim:close-hardware-agent", input),
onHardwareProviderEvent: (callback) => {
const listener = (_event, value) => callback(value);
ipcRenderer.on("sim:hardware-provider-event", listener);
return () => ipcRenderer.removeListener("sim:hardware-provider-event", listener);
},
saveHardwareReport: (report) => ipcRenderer.invoke("sim:save-hardware-report", report),
hardwareAgentEvent: (input) => ipcRenderer.invoke("sim:hardware-agent-event", input),
activateConversation: (input) => ipcRenderer.invoke("sim:activate-conversation", input),
getConversation: (conversationID) => ipcRenderer.invoke("sim:get-conversation", conversationID),
heartbeat: (input) => ipcRenderer.invoke("sim:heartbeat", input),
+2 -2
View File
@@ -4,10 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' data:; style-src 'self'; connect-src 'self' data: https://121.41.46.238 https://*.rtc.volcvideo.com https://*.volcvideos.com wss: http://127.0.0.1:* http://localhost:*; media-src 'self' blob:; worker-src 'self' blob: data:; img-src 'self' data:;" />
<title>语音终端模拟器</title>
<title>硬件对话智能体测试程序</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/hardware.ts"></script>
</body>
</html>
@@ -1,7 +1,8 @@
@echo off
setlocal
set "SECRET_FILE=%~dp0.server-device-secret"
set "DEVICE_EXE=%~dp0voice-device-simulator-0.1.0-windows-x64.exe"
set "SECRET_FILE=%LOCALAPPDATA%\DataTerminalHardwareAgent\server-device-secret"
set "DEVICE_EXE=%~dp0hardware-agent-tester-0.3.5-windows-x64.exe"
if not exist "%DEVICE_EXE%" set "DEVICE_EXE=%~dp0release\hardware-agent-tester-0.3.5-windows-x64.exe"
if not exist "%SECRET_FILE%" (
echo Cloud device credential is missing: %SECRET_FILE%
@@ -9,12 +10,12 @@ if not exist "%SECRET_FILE%" (
exit /b 1
)
if not exist "%DEVICE_EXE%" (
echo Voice device simulator is missing: %DEVICE_EXE%
echo Hardware agent tester is missing: %DEVICE_EXE%
pause
exit /b 1
)
set /p SIMULATOR_DEFAULT_DEVICE_SECRET=<"%SECRET_FILE%"
set "SIMULATOR_DEFAULT_API_URL=https://121.41.46.238"
set "SIMULATOR_DEFAULT_DEVICE_ID=10000000-0000-0000-0000-000000000511"
set "SIMULATOR_DEFAULT_DEVICE_ID=10000000-0000-0000-0000-000000000512"
start "" "%DEVICE_EXE%"
@@ -1,25 +1,46 @@
[CmdletBinding()]
param()
param(
[switch]$RealE2E,
[string]$ResultPath = (Join-Path $env:TEMP "hardware-agent-real-e2e.json")
)
$ErrorActionPreference = "Stop"
$secretPath = Join-Path $env:LOCALAPPDATA "DataTerminalVoiceDevice\server-device-secret"
$executablePath = Join-Path $PSScriptRoot "voice-device-simulator-0.1.0-windows-x64.exe"
$secretPath = Join-Path $env:LOCALAPPDATA "DataTerminalHardwareAgent\server-device-secret"
$executablePath = Join-Path $PSScriptRoot "hardware-agent-tester-0.3.5-windows-x64.exe"
if (-not (Test-Path -LiteralPath $executablePath -PathType Leaf)) {
$executablePath = Join-Path $PSScriptRoot "release\hardware-agent-tester-0.3.5-windows-x64.exe"
}
if (-not (Test-Path -LiteralPath $secretPath -PathType Leaf)) {
throw "Cloud device credential is missing: $secretPath"
}
if (-not (Test-Path -LiteralPath $executablePath -PathType Leaf)) {
throw "Voice device simulator is missing: $executablePath"
throw "Hardware agent tester is missing: $executablePath"
}
$env:SIMULATOR_DEFAULT_API_URL = "https://121.41.46.238"
$env:SIMULATOR_DEFAULT_DEVICE_ID = "10000000-0000-0000-0000-000000000511"
$env:SIMULATOR_DEFAULT_DEVICE_ID = "10000000-0000-0000-0000-000000000512"
$env:SIMULATOR_DEFAULT_DEVICE_SECRET = [IO.File]::ReadAllText($secretPath).Trim()
try {
Start-Process -FilePath $executablePath
if ($RealE2E) {
$env:SIMULATOR_REAL_E2E = "1"
$env:SIMULATOR_AUTO_RESULT_PATH = $ResultPath
$env:SIMULATOR_VOLC_MODEL_MODE = "llm"
$env:SIMULATOR_REAL_E2E_QUESTION = "Please introduce yourself in one short sentence."
$process = Start-Process -FilePath $executablePath -WindowStyle Hidden -PassThru -Wait
if ($process.ExitCode -ne 0) { throw "Hardware agent tester exited with code $($process.ExitCode)" }
if (-not (Test-Path -LiteralPath $ResultPath -PathType Leaf)) { throw "Real E2E report was not created: $ResultPath" }
}
else {
Start-Process -FilePath $executablePath
}
}
finally {
Remove-Item Env:SIMULATOR_DEFAULT_API_URL -ErrorAction SilentlyContinue
Remove-Item Env:SIMULATOR_DEFAULT_DEVICE_ID -ErrorAction SilentlyContinue
Remove-Item Env:SIMULATOR_DEFAULT_DEVICE_SECRET -ErrorAction SilentlyContinue
Remove-Item Env:SIMULATOR_REAL_E2E -ErrorAction SilentlyContinue
Remove-Item Env:SIMULATOR_AUTO_RESULT_PATH -ErrorAction SilentlyContinue
Remove-Item Env:SIMULATOR_VOLC_MODEL_MODE -ErrorAction SilentlyContinue
Remove-Item Env:SIMULATOR_REAL_E2E_QUESTION -ErrorAction SilentlyContinue
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' data:; style-src 'self'; connect-src 'self' data: https://121.41.46.238 https://*.rtc.volcvideo.com https://*.volcvideos.com wss: http://127.0.0.1:* http://localhost:*; media-src 'self' blob:; worker-src 'self' blob: data:; img-src 'self' data:;" />
<title>传统 RTC 语音终端模拟器</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+25 -3
View File
@@ -1,14 +1,15 @@
{
"name": "data-terminal-voice-device-simulator",
"version": "0.1.0",
"version": "0.3.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "data-terminal-voice-device-simulator",
"version": "0.1.0",
"version": "0.3.5",
"dependencies": {
"@volcengine/rtc": "4.69.0"
"@volcengine/rtc": "4.69.0",
"ws": "8.21.3"
},
"devDependencies": {
"concurrently": "^9.2.1",
@@ -4857,6 +4858,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
+13 -8
View File
@@ -1,18 +1,20 @@
{
"name": "data-terminal-voice-device-simulator",
"version": "0.1.0",
"version": "0.3.5",
"private": true,
"description": "Windows real-audio device simulator for the data terminal cloud platform",
"description": "Windows end-to-end tester for Volcengine hardware conversational agents",
"main": "electron/main.cjs",
"scripts": {
"dev": "concurrently -k \"vite --host 127.0.0.1 --port 5177\" \"wait-on http://127.0.0.1:5177 && electron . --dev-server-url=http://127.0.0.1:5177\"",
"typecheck": "tsc --noEmit",
"test": "node --test electron/*.test.cjs",
"build:renderer": "vite build",
"start": "npm run build:renderer && electron .",
"build": "npm run typecheck && npm run build:renderer && electron-builder --win portable"
"build": "npm test && npm run typecheck && npm run build:renderer && electron-builder --win portable"
},
"dependencies": {
"@volcengine/rtc": "4.69.0"
"@volcengine/rtc": "4.69.0",
"ws": "8.21.3"
},
"devDependencies": {
"concurrently": "^9.2.1",
@@ -23,22 +25,25 @@
"wait-on": "^8.0.4"
},
"build": {
"appId": "cn.dataterminal.voice-device-simulator",
"productName": "语音终端模拟器",
"artifactName": "voice-device-simulator-${version}-windows-${arch}.${ext}",
"appId": "cn.dataterminal.hardware-agent-tester",
"productName": "硬件对话智能体测试程序",
"artifactName": "hardware-agent-tester-${version}-windows-${arch}.${ext}",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"!electron/*.test.cjs",
"package.json"
],
"win": {
"target": [
{
"target": "portable",
"arch": ["x64"]
"arch": [
"x64"
]
}
]
}
@@ -0,0 +1,47 @@
class PCM16CaptureProcessor extends AudioWorkletProcessor {
constructor(options) {
super();
this.targetRate = Number(options.processorOptions?.targetSampleRate || 16000);
this.frameSamples = Number(options.processorOptions?.frameSamples || 320);
this.ratio = sampleRate / this.targetRate;
this.source = [];
this.position = 0;
this.output = [];
}
process(inputs) {
const channel = inputs[0]?.[0];
if (!channel?.length) return true;
for (const sample of channel) this.source.push(sample);
while (this.position + this.ratio <= this.source.length) {
const start = Math.floor(this.position);
const end = Math.max(start + 1, Math.floor(this.position + this.ratio));
let sum = 0;
for (let index = start; index < end && index < this.source.length; index += 1) sum += this.source[index];
this.output.push(sum / Math.max(1, end - start));
this.position += this.ratio;
if (this.output.length >= this.frameSamples) this.flush();
}
const consumed = Math.floor(this.position);
if (consumed > 0) {
this.source.splice(0, consumed);
this.position -= consumed;
}
return true;
}
flush() {
const frame = this.output.splice(0, this.frameSamples);
const pcm = new Int16Array(frame.length);
let energy = 0;
for (let index = 0; index < frame.length; index += 1) {
const sample = Math.max(-1, Math.min(1, frame[index]));
energy += sample * sample;
pcm[index] = sample < 0 ? Math.round(sample * 32768) : Math.round(sample * 32767);
}
const volume = Math.min(100, Math.round(Math.sqrt(energy / Math.max(1, frame.length)) * 280));
this.port.postMessage({ type: "pcm", bytes: pcm.buffer, volume }, [pcm.buffer]);
}
}
registerProcessor("pcm16-capture", PCM16CaptureProcessor);
@@ -0,0 +1,115 @@
class PCM16PlaybackProcessor extends AudioWorkletProcessor {
constructor(options) {
super();
const config = options.processorOptions || {};
this.sourceSampleRate = Number(config.sourceSampleRate || 16000);
this.prebufferSamples = Math.max(1, Math.round(this.sourceSampleRate * Number(config.prebufferMs || 120) / 1000));
this.capacity = Math.max(this.sourceSampleRate * 30, this.prebufferSamples * 4);
this.ring = new Float32Array(this.capacity);
this.readPosition = 0;
this.writePosition = 0;
this.playing = false;
this.ended = false;
this.drained = false;
this.lastOutput = 0;
this.fadeInRemaining = 0;
this.fadeFrames = Math.max(1, Math.round(sampleRate * 0.005));
this.underflows = 0;
this.port.onmessage = (message) => this.onMessage(message.data || {});
}
onMessage(message) {
if (message.type === "reset") {
this.reset();
return;
}
if (message.type === "end") {
this.ended = true;
return;
}
if (message.type !== "push" || !(message.samples instanceof ArrayBuffer)) return;
const samples = new Float32Array(message.samples);
if (this.drained) this.reset();
this.ended = false;
for (let index = 0; index < samples.length; index += 1) {
if (this.writePosition - Math.floor(this.readPosition) >= this.capacity - 1) {
this.readPosition = this.writePosition - this.capacity + 2;
}
this.ring[this.writePosition % this.capacity] = samples[index];
this.writePosition += 1;
}
}
reset() {
this.readPosition = 0;
this.writePosition = 0;
this.playing = false;
this.ended = false;
this.drained = false;
this.lastOutput = 0;
this.fadeInRemaining = 0;
}
available() {
return this.writePosition - this.readPosition;
}
startWhenReady() {
if (this.playing) return true;
const available = this.available();
if (available >= this.prebufferSamples || (this.ended && available > 1)) {
this.playing = true;
this.fadeInRemaining = this.fadeFrames;
this.port.postMessage({ type: "started", bufferedSamples: Math.floor(available) });
return true;
}
return false;
}
finish(output, fromIndex, eventType) {
const remaining = Math.min(this.fadeFrames, output.length - fromIndex);
for (let index = 0; index < remaining; index += 1) {
output[fromIndex + index] = this.lastOutput * (1 - (index + 1) / remaining);
}
output.fill(0, fromIndex + remaining);
this.lastOutput = 0;
this.playing = false;
if (eventType === "drained") {
if (!this.drained) this.port.postMessage({ type: "drained" });
this.drained = true;
} else {
this.underflows += 1;
this.port.postMessage({ type: "underflow", count: this.underflows });
}
}
process(_inputs, outputs) {
const output = outputs[0]?.[0];
if (!output) return true;
output.fill(0);
if (!this.startWhenReady()) return true;
const sourceStep = this.sourceSampleRate / sampleRate;
for (let index = 0; index < output.length; index += 1) {
const base = Math.floor(this.readPosition);
if (base + 1 >= this.writePosition) {
this.finish(output, index, this.ended ? "drained" : "underflow");
break;
}
const fraction = this.readPosition - base;
const first = this.ring[base % this.capacity];
const second = this.ring[(base + 1) % this.capacity];
let value = first + (second - first) * fraction;
if (this.fadeInRemaining > 0) {
value *= 1 - this.fadeInRemaining / this.fadeFrames;
this.fadeInRemaining -= 1;
}
output[index] = value;
this.lastOutput = value;
this.readPosition += sourceStep;
}
return true;
}
}
registerProcessor("pcm16-playback", PCM16PlaybackProcessor);
+72 -1
View File
@@ -34,6 +34,32 @@ type CreatedConversation = {
};
};
type CreatedHardwareAgentSession = {
conversation_id: string;
status: "SESSION_READY";
runtime_type: "volc_hardware_agent";
runtime: {
provider: "volcengine";
bot_id: string;
transport: "high_quality" | "low_load";
provider_session_ref: string;
session_nonce: string;
config_digest: string;
wait_for_session_update: boolean;
};
session_config: {
dialog_id: string;
system_prompt?: string;
persona_tone?: string;
authorized_context: string;
prompt_bundle_version: string;
tool_url?: string;
tool_scope_token?: string;
};
privacy: CreatedConversation["privacy"];
session: CreatedConversation["session"];
};
type DeviceFinalTurn = {
turn_key: string;
sequence_no: number;
@@ -50,15 +76,60 @@ interface Window {
configure(input: SimulatorConfiguration): Promise<{ apiURL: string; deviceID: string }>;
probe(): Promise<Record<string, unknown>>;
probeRTCNetwork(): Promise<{ endpoints: Array<{ hostname: string; ok: boolean; error?: string }> }>;
probeHardwareNetwork(): Promise<{ endpoints: Array<{ hostname: string; ok: boolean; error?: string }> }>;
getHardwareRuntimeDefaults(): Promise<{
instanceID: string;
productKey: string;
deviceName: string;
hardwareID: string;
modelMode: "llm" | "s2s";
deviceCredentialConfigured: boolean;
}>;
configureHardware(input: {
hardwareID: string;
}): Promise<{
instanceID: string;
productKey: string;
deviceName: string;
hardwareID: string;
registered: boolean;
persisted: boolean;
credentialSource: string;
credentialVersion: number;
}>;
getAutomationAudio(): Promise<{ data_url: string; source_name: string } | undefined>;
createConversation(): Promise<CreatedConversation>;
createHardwareAgentSession(): Promise<CreatedHardwareAgentSession>;
connectHardwareAgent(input: { platformSession: CreatedHardwareAgentSession; modelMode: "llm" | "s2s" }): Promise<{
providerConnectionRef: string;
session: Record<string, unknown>;
}>;
sendHardwareAgentAudio(bytes: ArrayBuffer): void;
sendHardwareAgentText(input: { text: string; interruptMode?: number }): Promise<{ accepted: boolean }>;
closeHardwareAgent(input?: { code?: number; reason?: string }): Promise<{ closed: boolean }>;
onHardwareProviderEvent(callback: (event: Record<string, unknown>) => void): () => void;
saveHardwareReport(report: Record<string, unknown>): Promise<{ saved: boolean; path?: string }>;
hardwareAgentEvent(input: {
conversationID: string;
eventID?: string;
sequenceNo: number;
eventType: "session_started" | "session_updated" | "session_ended" | "session_failed";
providerSessionRef: string;
sessionNonce: string;
providerConnectionRef?: string;
botID?: string;
transport?: "high_quality" | "low_load";
configDigest?: string;
dynamicConfigApplied?: boolean;
errorCode?: string;
}): Promise<{ conversation_id: string; status: string; action: "continue" | "stop_session"; duplicate: boolean }>;
activateConversation(input: { conversationID: string; roomID: string; userID: string }): Promise<Record<string, unknown>>;
getConversation(conversationID: string): Promise<{ status: string; action: string; failure_code?: string }>;
heartbeat(input: { conversationID: string; sequenceNo: number; credentialExpiresAt: string }): Promise<{
actions?: Array<{ type: string; rtc?: RTCGrant }>;
desired_config_revision?: number;
}>;
submitTranscriptBatch(input: { conversationID: string; turns: DeviceFinalTurn[] }): Promise<{
submitTranscriptBatch(input: { conversationID: string; turns: DeviceFinalTurn[]; hardwareAgent?: boolean }): Promise<{
conversation_id: string;
accepted_count: number;
duplicate_count: number;
+778
View File
@@ -0,0 +1,778 @@
import "./style.css";
type HardwarePhase = "idle" | "checking" | "ready" | "connecting" | "active" | "stopping" | "completed" | "error";
type ReportCheck = { id: string; label: string; status: "pending" | "passed" | "failed"; detail?: string; at?: string };
type ReportEvent = { at: string; source: "app" | "platform" | "volcengine"; type: string; detail?: Record<string, unknown> };
const checkDefinitions: Array<[string, string]> = [
["platform_authenticated", "平台设备 HMAC 鉴权"],
["credential_resolved", "火山一机一密凭据"],
["context_frozen", "人设、背景与授权上下文冻结"],
["provider_connected", "火山硬件智能体 WebSocket 建连"],
["dynamic_config_applied", "session.updated 动态配置确认"],
["platform_active", "平台确认会话 ACTIVE"],
["microphone_streaming", "16kHz PCM 麦克风实时上行"],
["audio_playback_buffered", "16kHz PCM 连续缓冲播放"],
["heartbeat_ack", "平台 session_updated 存活回执"],
["user_transcript", "用户最终字幕"],
["agent_transcript", "智能体最终字幕"],
["transcript_uploaded", "最终话轮可靠上传"],
["provider_closed", "火山连接正常关闭"],
["platform_completed", "平台确认会话 COMPLETED"],
];
const state: {
phase: HardwarePhase;
platformSession?: CreatedHardwareAgentSession;
providerConnectionRef?: string;
eventSequence: number;
transcriptSequence: number;
pendingTurns: DeviceFinalTurn[];
transcriptUpload?: Promise<void>;
heartbeatTimer?: number;
durationTimer?: number;
startedAt?: number;
stopping: boolean;
intentionalProviderClose: boolean;
microphoneStream?: MediaStream;
captureContext?: AudioContext;
captureNode?: AudioWorkletNode;
playbackContext?: AudioContext;
playbackNode?: AudioWorkletNode;
playbackReady?: Promise<AudioWorkletNode>;
playbackPendingByte?: number;
playbackChunks: number;
playbackSamples: number;
playbackUnderflows: number;
responseTranscripts: Map<string, string>;
speechStartedAt?: number;
checks: Map<string, ReportCheck>;
events: ReportEvent[];
turns: DeviceFinalTurn[];
failure?: string;
failureTask?: Promise<void>;
} = {
phase: "idle",
eventSequence: 0,
transcriptSequence: 0,
pendingTurns: [],
stopping: false,
intentionalProviderClose: false,
playbackChunks: 0,
playbackSamples: 0,
playbackUnderflows: 0,
responseTranscripts: new Map(),
checks: new Map(checkDefinitions.map(([id, label]) => [id, { id, label, status: "pending" }])),
events: [],
turns: [],
};
const app = document.querySelector<HTMLElement>("#app")!;
app.innerHTML = `
<section class="shell hardware-shell">
<header class="hero">
<div>
<p class="eyebrow">WINDOWS · VOLC HARDWARE AGENT · LOW LOAD</p>
<h1>硬件对话智能体测试程序</h1>
<p class="subtitle">真实连接平台设备 v3 控制面和火山硬件对话智能体 WebSocket,验证动态人设、PCM 双向语音、字幕、记忆工具、平台停止与结束闭环。</p>
</div>
<div class="hero-actions"><a class="legacy-link" href="./legacy.html">传统 RTC 测试页</a><div class="overall" id="overallStatus"><strong>尚未检测</strong></div></div>
</header>
<section class="status-grid" aria-label="链路状态">
<article class="status-card"><span>平台 v3</span><strong id="platformStatus">未检测</strong><small id="platformDetail">等待设备鉴权</small></article>
<article class="status-card"><span>火山设备凭据</span><strong id="credentialStatus">未配置</strong><small id="credentialDetail">支持动态注册</small></article>
<article class="status-card"><span>动态会话配置</span><strong id="configStatus">未应用</strong><small id="configDetail">麦克风保持关闭</small></article>
<article class="status-card"><span>通话时长</span><strong id="duration">00:00</strong><small id="providerStatus">网关未连接</small></article>
</section>
<section class="content-grid hardware-content-grid">
<article class="panel setup-panel">
<div class="panel-heading"><div><p class="eyebrow">SECURE SETUP</p><h2>平台与火山设备</h2></div><span class="safe">密钥不写入报告</span></div>
<h3>平台设备身份</h3>
<label>平台地址<input id="apiURL" value="https://121.41.46.238" autocomplete="off" /></label>
<label>平台设备 ID<input id="deviceID" autocomplete="off" /></label>
<label>平台设备签名密钥<input id="deviceSecret" type="password" autocomplete="off" /></label>
<h3>火山硬件身份</h3>
<div class="row"><label>实例 ID(平台下发)<input id="instanceID" readonly /></label><label>Product Key(平台下发)<input id="productKey" readonly /></label></div>
<div class="row"><label>设备名称(平台分配)<input id="volcDeviceName" readonly /></label><label>Hardware ID<input id="hardwareID" autocomplete="off" /></label></div>
<label>智能体模型类型<select id="modelMode"><option value="llm">通用 LLM(LLMConfig.SystemMessages)</option><option value="s2s">豆包端到端语音(S2S dialog)</option></select></label>
<label>麦克风<select id="microphone"><option value="">默认麦克风</option></select></label>
<button class="secondary" id="checkButton">检测、鉴权并准备设备</button>
<p class="hint">Product Secret 只保存在平台密钥服务中。平台代设备完成动态注册,设备仅接收自己的一机一密并使用 Windows Safe Storage 加密保存;默认 Bot 由平台产品配置自动分配,设备不能覆盖。</p>
</article>
<article class="panel call-panel hardware-call-panel">
<div class="panel-heading"><div><p class="eyebrow">REAL END-TO-END FLOW</p><h2>硬件智能体通话</h2></div><span class="phase-badge" id="phaseBadge">待机</span></div>
<div class="conversation-orb" id="conversationOrb"><div class="orb-core"><span id="orbLabel">准备检测</span></div></div>
<div class="meters">
<div><div class="meter-label"><span>16k PCM 上行</span><strong id="micValue">0</strong></div><div class="meter"><i id="micMeter"></i></div></div>
<div><div class="meter-label"><span>16k PCM 下行</span><strong id="agentValue">0</strong></div><div class="meter agent"><i id="agentMeter"></i></div></div>
</div>
<div class="call-actions"><button class="primary" id="startButton" disabled>开始硬件智能体通话</button><button class="danger" id="stopButton" disabled>结束通话</button></div>
<div class="text-question"><input id="textQuestion" placeholder="可选:输入文字验证智能体回复" /><button class="secondary" id="sendTextButton" disabled>发送文本问题</button></div>
<dl class="session-info hardware-session-info">
<div><dt>平台通话编号</dt><dd id="conversationID">—</dd></div>
<div><dt>产品默认 Bot</dt><dd id="botID">—</dd></div>
<div><dt>传输方案</dt><dd id="transport">low_load</dd></div>
<div><dt>火山会话编号</dt><dd id="providerConnectionRef">—</dd></div>
<div><dt>配置摘要</dt><dd id="configDigest">—</dd></div>
<div><dt>冻结提示版本</dt><dd id="promptVersion">—</dd></div>
</dl>
</article>
</section>
<section class="panel flow-panel">
<div class="panel-heading"><div><p class="eyebrow">ACCEPTANCE GATES</p><h2>完整流程验收</h2></div><button class="text-button" id="exportReport">导出测试报告</button></div>
<ol id="flowChecks" class="flow-checks"></ol>
</section>
<section class="panel transcript-panel">
<div class="panel-heading"><div><p class="eyebrow">FINAL TRANSCRIPTS</p><h2>双方最终字幕与平台上传</h2></div><span class="phase-badge" id="transcriptStatus">0 条</span></div>
<ol id="liveTranscript"><li class="transcript-empty">通话开始后显示火山返回的用户和智能体最终字幕。</li></ol>
</section>
<section class="panel log-panel">
<div class="panel-heading"><div><p class="eyebrow">AUDITABLE EVENT STREAM</p><h2>平台与火山事件</h2></div><button class="text-button" id="clearLog">清空显示</button></div>
<ol id="eventLog"><li><time>系统</time><span>测试程序已启动,等待环境检测。</span></li></ol>
</section>
</section>
`;
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const controls = {
apiURL: $<HTMLInputElement>("apiURL"), deviceID: $<HTMLInputElement>("deviceID"), deviceSecret: $<HTMLInputElement>("deviceSecret"),
instanceID: $<HTMLInputElement>("instanceID"), productKey: $<HTMLInputElement>("productKey"),
volcDeviceName: $<HTMLInputElement>("volcDeviceName"), hardwareID: $<HTMLInputElement>("hardwareID"),
modelMode: $<HTMLSelectElement>("modelMode"),
microphone: $<HTMLSelectElement>("microphone"), check: $<HTMLButtonElement>("checkButton"), start: $<HTMLButtonElement>("startButton"),
stop: $<HTMLButtonElement>("stopButton"), textQuestion: $<HTMLInputElement>("textQuestion"), sendText: $<HTMLButtonElement>("sendTextButton"),
};
function setText(id: string, value: string) { $(id).textContent = value; }
function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); }
function recordEvent(source: ReportEvent["source"], type: string, detail?: Record<string, unknown>) {
state.events.push({ at: new Date().toISOString(), source, type, detail });
if (state.events.length > 1000) state.events.splice(0, state.events.length - 1000);
}
function log(message: string, level: "info" | "success" | "error" = "info", source: ReportEvent["source"] = "app", type = "log") {
const item = document.createElement("li");
item.className = level;
const time = document.createElement("time");
time.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
const content = document.createElement("span");
content.textContent = message;
item.append(time, content);
$("eventLog").prepend(item);
recordEvent(source, type, { level, message });
}
function setPhase(phase: HardwarePhase, detail: string) {
state.phase = phase;
const labels: Record<HardwarePhase, string> = { idle: "待机", checking: "检测中", ready: "已就绪", connecting: "连接中", active: "通话中", stopping: "结束中", completed: "已完成", error: "异常" };
setText("phaseBadge", labels[phase]);
setText("orbLabel", detail);
setText("overallStatus", detail);
$("overallStatus").className = `overall phase-${phase}`;
$("conversationOrb").className = `conversation-orb phase-${phase}`;
controls.check.disabled = ["connecting", "active", "stopping"].includes(phase);
controls.start.disabled = phase !== "ready" && phase !== "completed";
controls.stop.disabled = !["active", "error"].includes(phase) || !state.platformSession || !!state.failureTask;
controls.sendText.disabled = phase !== "active";
}
function setCheck(id: string, status: ReportCheck["status"], detail?: string) {
const check = state.checks.get(id);
if (!check) return;
check.status = status;
check.detail = detail;
check.at = new Date().toISOString();
renderChecks();
}
function resetChecks() {
state.checks = new Map(checkDefinitions.map(([id, label]) => [id, { id, label, status: "pending" }]));
renderChecks();
}
function renderChecks() {
const list = $("flowChecks");
list.replaceChildren(...[...state.checks.values()].map((check) => {
const item = document.createElement("li");
item.className = `flow-check flow-check--${check.status}`;
const mark = document.createElement("i");
mark.textContent = check.status === "passed" ? "✓" : check.status === "failed" ? "!" : "·";
const content = document.createElement("span");
content.textContent = check.label;
const detail = document.createElement("small");
detail.textContent = check.detail || (check.status === "pending" ? "等待验证" : check.status === "passed" ? "已通过" : "未通过");
item.append(mark, content, detail);
return item;
}));
}
function updateMeter(kind: "mic" | "agent", value: number) {
const normalized = Math.max(0, Math.min(100, Math.round(value)));
$(`${kind}Meter`).style.width = `${normalized}%`;
setText(`${kind}Value`, String(normalized));
}
async function loadDefaults() {
const [platform, hardware] = await Promise.all([
window.deviceSimulator.getRuntimeDefaults(),
window.deviceSimulator.getHardwareRuntimeDefaults(),
]);
controls.apiURL.value = platform.apiURL;
controls.deviceID.value = platform.deviceID;
if (platform.deviceSecretConfigured) controls.deviceSecret.placeholder = "已从 Windows 安全凭据文件加载";
controls.instanceID.value = hardware.instanceID;
controls.productKey.value = hardware.productKey;
controls.volcDeviceName.value = hardware.deviceName;
controls.hardwareID.value = hardware.hardwareID;
controls.modelMode.value = hardware.modelMode;
if (hardware.deviceCredentialConfigured) setText("credentialDetail", "本机已有平台签发的一机一密安全缓存");
}
async function enumerateMicrophones() {
const permission = await navigator.mediaDevices.getUserMedia({ audio: true });
permission.getTracks().forEach((track) => track.stop());
const devices = (await navigator.mediaDevices.enumerateDevices()).filter((device) => device.kind === "audioinput");
controls.microphone.replaceChildren(new Option("默认麦克风", ""));
devices.forEach((device, index) => controls.microphone.add(new Option(device.label || `麦克风 ${index + 1}`, device.deviceId)));
}
async function configureAndProbe() {
if (state.failureTask) await state.failureTask;
setPhase("checking", "正在鉴权和检测");
resetChecks();
state.events = [];
state.turns = [];
state.failure = undefined;
try {
await window.deviceSimulator.configure({ apiURL: controls.apiURL.value, deviceID: controls.deviceID.value, deviceSecret: controls.deviceSecret.value });
await window.deviceSimulator.probe();
setCheck("platform_authenticated", "passed", "设备 bootstrap/config HMAC 请求成功");
setText("platformStatus", "鉴权成功");
setText("platformDetail", controls.apiURL.value);
const hardware = await window.deviceSimulator.configureHardware({ hardwareID: controls.hardwareID.value });
controls.instanceID.value = hardware.instanceID;
controls.productKey.value = hardware.productKey;
controls.volcDeviceName.value = hardware.deviceName;
controls.hardwareID.value = hardware.hardwareID;
setCheck("credential_resolved", "passed", `${hardware.credentialSource}${hardware.persisted ? ",Device Secret 已写入 Windows 安全存储" : ",仅本次内存"}`);
setText("credentialStatus", hardware.registered ? "平台注册成功" : "平台凭据可用");
setText("credentialDetail", `${hardware.deviceName} · ${hardware.credentialSource} · 火山 License 额度已在动态注册时由服务端消费`);
const network = await window.deviceSimulator.probeHardwareNetwork();
const failures = network.endpoints.filter((endpoint) => !endpoint.ok);
if (failures.length) throw new Error(`火山网络不可达:${failures.map((item) => `${item.hostname} ${item.error || "失败"}`).join(";")}`);
await enumerateMicrophones();
setPhase("ready", "可以开始真实通话");
log("平台 v3、平台代理注册、火山网关和麦克风检测通过。", "success");
} catch (error) {
state.failure = errorMessage(error);
setPhase("error", "环境检测失败");
setText("platformStatus", "检测失败");
setText("platformDetail", state.failure);
log(`环境检测失败:${state.failure}`, "error");
}
}
function renderPlatformSession(created: CreatedHardwareAgentSession) {
setText("conversationID", created.conversation_id);
setText("botID", created.runtime.bot_id);
setText("transport", created.runtime.transport);
setText("configDigest", created.runtime.config_digest);
setText("promptVersion", created.session_config.prompt_bundle_version);
setText("providerConnectionRef", "等待火山连接");
}
async function startHardwareConversation() {
if (!["ready", "completed"].includes(state.phase)) return;
if (state.failureTask) await state.failureTask;
setPhase("connecting", "正在冻结本次上下文");
resetSessionState();
try {
const created = await window.deviceSimulator.createHardwareAgentSession();
state.platformSession = created;
renderPlatformSession(created);
if (created.runtime.transport !== "low_load") throw new Error(`设备绑定传输模式为 ${created.runtime.transport},Windows 标准协议测试要求 low_load`);
if (!created.session_config.authorized_context || !created.runtime.config_digest || !created.runtime.session_nonce) throw new Error("平台未返回完整冻结上下文或会话凭证");
setCheck("context_frozen", "passed", `${created.session_config.prompt_bundle_version} · ${created.runtime.config_digest.slice(0, 12)}…`);
recordEvent("platform", "hardware.session.ready", { conversation_id: created.conversation_id, bot_id: created.runtime.bot_id, transport: created.runtime.transport });
setText("configStatus", "等待 session.updated");
setText("configDetail", "麦克风仍保持关闭");
const connected = await window.deviceSimulator.connectHardwareAgent({ platformSession: created, modelMode: controls.modelMode.value as "llm" | "s2s" });
if (state.failureTask) { await state.failureTask; return; }
state.providerConnectionRef = connected.providerConnectionRef;
setText("providerConnectionRef", connected.providerConnectionRef);
setText("providerStatus", "WebSocket 已连接");
setCheck("provider_connected", "passed", connected.providerConnectionRef);
setCheck("dynamic_config_applied", "passed", "火山已返回 session.updated");
setText("configStatus", "已应用");
setText("configDetail", "允许激活麦克风");
state.eventSequence = 1;
const activated = await window.deviceSimulator.hardwareAgentEvent({
conversationID: created.conversation_id, sequenceNo: state.eventSequence, eventType: "session_started",
providerSessionRef: created.runtime.provider_session_ref, sessionNonce: created.runtime.session_nonce,
providerConnectionRef: connected.providerConnectionRef, botID: created.runtime.bot_id, transport: created.runtime.transport,
configDigest: created.runtime.config_digest, dynamicConfigApplied: true,
});
if (activated.status !== "ACTIVE" || activated.action !== "continue") throw new Error(`平台未激活硬件会话:${activated.status}/${activated.action}`);
if (state.failureTask) { await state.failureTask; return; }
setCheck("platform_active", "passed", "session_started 已进入 ACTIVE");
await ensurePlaybackNode();
await startMicrophone();
if (state.failureTask) { await state.failureTask; await stopAudio(); return; }
setCheck("microphone_streaming", "passed", "PCM16/16kHz/单声道/20ms 分片");
state.startedAt = Date.now();
setPhase("active", "请开始说话");
startTimers(created.session.heartbeat_interval_sec || 15);
log("硬件对话智能体已激活:动态配置确认后才开放麦克风。", "success", "platform", "hardware.session.active");
} catch (error) {
state.failure ||= errorMessage(error);
log(`启动硬件智能体失败:${state.failure}`, "error");
await failHardwareConversation("WINDOWS_HARDWARE_START_FAILED");
}
}
function resetSessionState() {
stopTimers();
state.platformSession = undefined;
state.providerConnectionRef = undefined;
state.eventSequence = 0;
state.transcriptSequence = 0;
state.pendingTurns = [];
state.transcriptUpload = undefined;
state.startedAt = undefined;
state.stopping = false;
state.intentionalProviderClose = false;
state.failureTask = undefined;
state.failure = undefined;
state.responseTranscripts.clear();
state.speechStartedAt = undefined;
state.turns = [];
state.playbackPendingByte = undefined;
state.playbackChunks = 0;
state.playbackSamples = 0;
state.playbackUnderflows = 0;
// Device preparation remains valid, but every call must earn its own checks.
for (const [id, label] of checkDefinitions) {
if (!["platform_authenticated", "credential_resolved"].includes(id)) {
state.checks.set(id, { id, label, status: "pending" });
}
}
renderChecks();
setText("duration", "00:00");
setText("providerStatus", "网关未连接");
setText("providerConnectionRef", "—");
setText("conversationID", "—");
setText("configStatus", "未应用");
setText("configDetail", "麦克风保持关闭");
$("liveTranscript").innerHTML = '<li class="transcript-empty">通话开始后显示火山返回的用户和智能体最终字幕。</li>';
setText("transcriptStatus", "0 条");
updateMeter("mic", 0);
updateMeter("agent", 0);
}
async function startMicrophone() {
const constraints: MediaTrackConstraints = { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true };
if (controls.microphone.value) constraints.deviceId = { exact: controls.microphone.value };
const stream = await navigator.mediaDevices.getUserMedia({ audio: constraints });
const context = new AudioContext({ latencyHint: "interactive" });
await context.audioWorklet.addModule(new URL("./pcm-capture.worklet.js", window.location.href).toString());
const source = context.createMediaStreamSource(stream);
const node = new AudioWorkletNode(context, "pcm16-capture", { processorOptions: { targetSampleRate: 16000, frameSamples: 320 } });
const silent = context.createGain();
silent.gain.value = 0;
node.port.onmessage = (message) => {
if (state.phase !== "active" && state.phase !== "connecting") return;
if (message.data?.type !== "pcm" || !(message.data.bytes instanceof ArrayBuffer)) return;
updateMeter("mic", Number(message.data.volume || 0));
window.deviceSimulator.sendHardwareAgentAudio(message.data.bytes);
};
source.connect(node).connect(silent).connect(context.destination);
await context.resume();
state.microphoneStream = stream;
state.captureContext = context;
state.captureNode = node;
}
async function stopAudio() {
state.microphoneStream?.getTracks().forEach((track) => track.stop());
state.captureNode?.disconnect();
state.playbackNode?.port.postMessage({ type: "reset" });
state.playbackNode?.disconnect();
if (state.captureContext) try { await state.captureContext.close(); } catch { /* no-op */ }
if (state.playbackContext) try { await state.playbackContext.close(); } catch { /* no-op */ }
state.microphoneStream = undefined;
state.captureNode = undefined;
state.captureContext = undefined;
state.playbackNode = undefined;
state.playbackReady = undefined;
state.playbackContext = undefined;
state.playbackPendingByte = undefined;
updateMeter("mic", 0);
updateMeter("agent", 0);
}
function ensurePlaybackNode(): Promise<AudioWorkletNode> {
if (state.playbackNode) return Promise.resolve(state.playbackNode);
if (state.playbackReady) return state.playbackReady;
state.playbackReady = (async () => {
const context = new AudioContext({ latencyHint: "interactive" });
state.playbackContext = context;
await context.audioWorklet.addModule(new URL("./pcm-playback.worklet.js", window.location.href).toString());
const node = new AudioWorkletNode(context, "pcm16-playback", {
processorOptions: { sourceSampleRate: 16000, prebufferMs: 120 },
outputChannelCount: [1],
});
node.port.onmessage = (message) => {
if (message.data?.type === "underflow") {
state.playbackUnderflows = Number(message.data.count || state.playbackUnderflows + 1);
recordEvent("app", "audio.playback.underflow", { count: state.playbackUnderflows });
}
if (message.data?.type === "drained") {
updateMeter("agent", 0);
setCheck("audio_playback_buffered", "passed", `${state.playbackChunks} 个分片 · ${state.playbackSamples} 个采样 · 欠载 ${state.playbackUnderflows} 次`);
}
};
node.connect(context.destination);
await context.resume();
state.playbackNode = node;
return node;
})().catch(async (error) => {
const context = state.playbackContext;
state.playbackReady = undefined;
state.playbackContext = undefined;
if (context) try { await context.close(); } catch { /* no-op */ }
throw error;
});
return state.playbackReady;
}
async function playProviderAudio(base64: string) {
if (!base64) return;
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
const totalBytes = bytes.byteLength + (state.playbackPendingByte === undefined ? 0 : 1);
const sampleCount = Math.floor(totalBytes / 2);
if (sampleCount === 0) {
if (bytes.byteLength === 1) state.playbackPendingByte = bytes[0];
return;
}
const samples = new Float32Array(sampleCount);
let energy = 0;
let byteIndex = 0;
let sampleIndex = 0;
if (state.playbackPendingByte !== undefined) {
const value = new DataView(new Uint8Array([state.playbackPendingByte, bytes[0]]).buffer).getInt16(0, true) / 32768;
samples[sampleIndex] = value;
energy += value * value;
sampleIndex += 1;
byteIndex = 1;
state.playbackPendingByte = undefined;
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
while (sampleIndex < sampleCount) {
const value = view.getInt16(byteIndex, true) / 32768;
samples[sampleIndex] = value;
energy += value * value;
sampleIndex += 1;
byteIndex += 2;
}
if (byteIndex < bytes.byteLength) state.playbackPendingByte = bytes[byteIndex];
updateMeter("agent", Math.sqrt(energy / Math.max(1, sampleCount)) * 280);
state.playbackChunks += 1;
state.playbackSamples += sampleCount;
const node = await ensurePlaybackNode();
node.port.postMessage({ type: "push", samples: samples.buffer }, [samples.buffer]);
setCheck("audio_playback_buffered", "passed", `已接收并缓冲 ${state.playbackChunks} 个火山音频分片`);
}
function sanitizeProviderEvent(event: Record<string, unknown>) {
const redact = (value: unknown, key = ""): unknown => {
if (/systemmessages|system_role|system_prompt|instructions|authorized_context|speaking_style/i.test(key)) return "[REDACTED]";
if (Array.isArray(value)) return value.map((item) => redact(item));
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([itemKey, item]) => [itemKey, redact(item, itemKey)]));
};
const copy = redact(event) as Record<string, unknown>;
if (typeof copy.delta === "string" && event.type === "response.audio.delta") copy.delta = `<pcm-base64:${copy.delta.length} chars>`;
return copy;
}
async function handleProviderEvent(event: Record<string, unknown>) {
const type = String(event.type || "unknown");
recordEvent("volcengine", type, sanitizeProviderEvent(event));
if (type !== "response.audio.delta") log(`火山事件:${type}`, type === "error" ? "error" : "info", "volcengine", type);
// Teardown can still deliver queued provider events. Do not revive a failed call.
if (state.failureTask && type !== "transport.close") return;
switch (type) {
case "session.created": setText("providerStatus", "会话已创建"); break;
case "session.updated": setText("configStatus", "已确认"); break;
case "input_audio_buffer.speech_started": state.speechStartedAt = Date.now(); setPhase("active", "检测到用户说话"); break;
case "input_audio_buffer.speech_stopped": setPhase("active", "智能体正在思考"); break;
case "response.created": setPhase("active", "智能体正在生成"); break;
case "response.audio.delta": await playProviderAudio(String(event.delta || "")); break;
case "response.audio.done": state.playbackNode?.port.postMessage({ type: "end" }); break;
case "conversation.item.input_audio_transcription.completed":
await appendFinalTurn("subject", String(event.transcript || ""), String(event.event_id || crypto.randomUUID()), state.speechStartedAt); state.speechStartedAt = undefined; break;
case "response.audio_transcript.delta": {
const responseID = String(event.response_id || "unknown");
state.responseTranscripts.set(responseID, `${state.responseTranscripts.get(responseID) || ""}${String(event.delta || "")}`);
break;
}
case "response.audio_transcript.done": {
const responseID = String(event.response_id || "unknown");
const transcript = String(event.transcript || state.responseTranscripts.get(responseID) || "");
state.responseTranscripts.delete(responseID);
await appendFinalTurn("agent", transcript, responseID); break;
}
case "response.done": setPhase("active", "继续聆听"); break;
case "platform.memory_search.completed": log(`平台记忆工具返回 ${Number(event.result_count || 0)} 条已授权记忆。`, "success", "platform", type); break;
case "platform.memory_search.failed": log(`平台记忆工具降级:${String(event.message || "未知错误")}`, "error", "platform", type); break;
case "error": {
const providerError = event.error as Record<string, unknown> | undefined;
state.failure ||= `${String(providerError?.code || providerError?.type || "PROVIDER_ERROR")}: ${String(providerError?.message || "火山智能体错误")}`;
log(`火山错误:${state.failure}`, "error", "volcengine", "provider.error");
await failHardwareConversation("VOLC_HARDWARE_AGENT_ERROR");
break;
}
case "transport.close":
setText("providerStatus", "连接已关闭");
if (state.failureTask) setCheck("provider_closed", "failed", `失败清理关闭:WebSocket ${Number(event.code || 1000)}`);
else if (state.intentionalProviderClose) setCheck("provider_closed", "passed", `WebSocket ${Number(event.code || 1000)}`);
else if (["connecting", "active"].includes(state.phase)) { state.failure = `火山连接意外关闭:${String(event.code || "")}`; await failHardwareConversation("VOLC_CONNECTION_CLOSED"); }
break;
}
}
async function appendFinalTurn(speaker: "subject" | "agent", rawText: string, sourceID: string, startedAt?: number) {
const text = rawText.trim();
if (!text || !state.platformSession) return;
state.transcriptSequence += 1;
const now = Date.now();
const turn: DeviceFinalTurn = {
turn_key: `hardware-${speaker}-${sourceID}`.slice(0, 240), sequence_no: state.transcriptSequence, speaker,
start_at: new Date(startedAt || now).toISOString(), end_at: new Date(now).toISOString(), final_text: text, interrupted: false,
};
state.pendingTurns.push(turn);
state.turns.push(turn);
renderTurn(turn);
setCheck(speaker === "subject" ? "user_transcript" : "agent_transcript", "passed", text.slice(0, 50));
await flushTurns();
}
function renderTurn(turn: DeviceFinalTurn) {
const list = $("liveTranscript");
list.querySelector(".transcript-empty")?.remove();
const item = document.createElement("li");
item.className = `transcript-turn transcript-turn--${turn.speaker}`;
const speaker = document.createElement("strong"); speaker.textContent = turn.speaker === "subject" ? "用户" : "智能体";
const text = document.createElement("span"); text.textContent = turn.final_text;
item.append(speaker, text); list.append(item);
setText("transcriptStatus", `${state.turns.length} 条`);
}
async function flushTurns() {
if (state.transcriptUpload) return state.transcriptUpload;
if (!state.platformSession || state.pendingTurns.length === 0) return;
const batch = state.pendingTurns.slice(0, 500);
state.transcriptUpload = (async () => {
try {
const result = await window.deviceSimulator.submitTranscriptBatch({ conversationID: state.platformSession!.conversation_id, turns: batch, hardwareAgent: true });
state.pendingTurns.splice(0, batch.length);
setCheck("transcript_uploaded", "passed", `新增 ${result.accepted_count},重复 ${result.duplicate_count}`);
log(`最终字幕已上传平台:新增 ${result.accepted_count},重复 ${result.duplicate_count}。`, "success", "platform", "hardware.turns.uploaded");
} catch (error) { log(`字幕上传失败,保留队列待重试:${errorMessage(error)}`, "error"); }
finally { state.transcriptUpload = undefined; }
})();
await state.transcriptUpload;
}
async function flushTurnsWithRetry(attempts: number) {
for (let attempt = 1; attempt <= attempts && state.pendingTurns.length > 0; attempt += 1) {
await flushTurns();
if (state.pendingTurns.length && attempt < attempts) await new Promise((resolve) => window.setTimeout(resolve, attempt * 500));
}
if (state.pendingTurns.length) throw new Error(`仍有 ${state.pendingTurns.length} 条最终字幕未上传`);
}
function startTimers(intervalSeconds: number) {
stopTimers();
state.durationTimer = window.setInterval(() => {
if (!state.startedAt) return;
const seconds = Math.floor((Date.now() - state.startedAt) / 1000);
setText("duration", `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`);
}, 1000);
state.heartbeatTimer = window.setInterval(() => void sendHardwareHeartbeat(), Math.max(5, intervalSeconds) * 1000);
void sendHardwareHeartbeat();
}
function stopTimers() {
if (state.durationTimer) window.clearInterval(state.durationTimer);
if (state.heartbeatTimer) window.clearInterval(state.heartbeatTimer);
state.durationTimer = undefined;
state.heartbeatTimer = undefined;
}
async function sendHardwareHeartbeat() {
const created = state.platformSession;
if (!created || state.phase !== "active" || state.stopping) return;
try {
state.eventSequence += 1;
const result = await window.deviceSimulator.hardwareAgentEvent({
conversationID: created.conversation_id, sequenceNo: state.eventSequence, eventType: "session_updated",
providerSessionRef: created.runtime.provider_session_ref, sessionNonce: created.runtime.session_nonce,
});
setCheck("heartbeat_ack", "passed", `序号 ${state.eventSequence} · ${result.action}`);
if (result.action === "stop_session") await stopHardwareConversation("platform_stop_session");
await flushTurns();
} catch (error) { log(`硬件会话存活上报失败:${errorMessage(error)}`, "error"); }
}
async function stopHardwareConversation(reason = "windows_user_hangup") {
if (state.failureTask) return state.failureTask;
if (!state.platformSession || state.stopping) return;
state.stopping = true;
setPhase("stopping", "正在可靠结束");
stopTimers();
const created = state.platformSession;
try {
await stopAudio();
await flushTurnsWithRetry(3);
state.intentionalProviderClose = true;
await window.deviceSimulator.closeHardwareAgent({ code: 1000, reason });
setCheck("provider_closed", "passed", "设备主动关闭 WebSocket");
state.eventSequence += 1;
const ended = await window.deviceSimulator.hardwareAgentEvent({
conversationID: created.conversation_id, sequenceNo: state.eventSequence, eventType: "session_ended",
providerSessionRef: created.runtime.provider_session_ref, sessionNonce: created.runtime.session_nonce,
});
if (ended.status !== "COMPLETED") throw new Error(`平台结束状态不是 COMPLETED:${ended.status}`);
setCheck("platform_completed", "passed", "session_ended 已进入 COMPLETED");
state.stopping = false;
setPhase("completed", allRequiredChecksPassed() ? "完整流程通过" : "通话完成,部分验收项待覆盖");
log("硬件对话智能体会话已正常结束,平台状态为 COMPLETED。", "success", "platform", "hardware.session.completed");
} catch (error) {
state.failure = errorMessage(error);
state.stopping = false;
setPhase("error", "结束流程失败");
log(`结束硬件会话失败:${state.failure}`, "error");
}
}
function failHardwareConversation(errorCode: string): Promise<void> {
// The provider error event and connect() rejection describe the same failure.
// Install a shared task before any asynchronous cleanup, and retain it until
// the next call so later errors/hangup cannot submit another terminal event.
if (state.failureTask) return state.failureTask;
const created = state.platformSession;
state.failureTask = Promise.resolve().then(async () => {
try {
await stopAudio();
try { await window.deviceSimulator.closeHardwareAgent({ code: 1011, reason: errorCode }); } catch { /* no-op */ }
if (created) {
try {
state.eventSequence += 1;
await window.deviceSimulator.hardwareAgentEvent({
conversationID: created.conversation_id, sequenceNo: state.eventSequence, eventType: "session_failed",
providerSessionRef: created.runtime.provider_session_ref, sessionNonce: created.runtime.session_nonce,
errorCode,
});
} catch (error) { log(`平台失败事件回报未成功:${errorMessage(error)}`, "error"); }
}
} finally {
state.stopping = false;
setPhase("error", state.failure || "硬件会话失败");
}
});
stopTimers();
state.stopping = true;
state.intentionalProviderClose = true;
setPhase("stopping", "正在清理失败会话");
setCheck("platform_completed", "failed", state.failure || errorCode);
if (!state.providerConnectionRef) {
setText("configStatus", "未完成");
setCheck("dynamic_config_applied", "failed", state.failure || errorCode);
}
return state.failureTask;
}
function allRequiredChecksPassed() {
const required = ["platform_authenticated", "credential_resolved", "context_frozen", "provider_connected", "dynamic_config_applied", "platform_active", "microphone_streaming", "audio_playback_buffered", "heartbeat_ack", "transcript_uploaded", "provider_closed", "platform_completed"];
return required.every((id) => state.checks.get(id)?.status === "passed");
}
async function sendTextQuestion() {
if (state.phase !== "active" || state.failureTask) return;
const text = controls.textQuestion.value.trim();
if (!text) return;
try {
await window.deviceSimulator.sendHardwareAgentText({ text, interruptMode: 1 });
// 文本输入没有 ASR final 事件,设备本地将已被火山接受的最终输入作为用户话轮上报,
// 这样文本验收与真实语音验收都会进入同一套平台聊天记录。
await appendFinalTurn("subject", text, `text-${crypto.randomUUID()}`);
controls.textQuestion.value = "";
log(`已通过硬件标准协议发送文本问题:${text}`, "success", "app", "hardware.text.sent");
} catch (error) { log(`发送文本问题失败:${errorMessage(error)}`, "error"); }
}
function buildReport(): Record<string, unknown> {
return {
schema_version: "hardware-agent-windows-e2e-report-v1",
generated_at: new Date().toISOString(),
result: state.phase === "completed" && allRequiredChecksPassed() ? "PASS" : state.failure ? "FAIL" : "INCOMPLETE",
platform_api: controls.apiURL.value,
platform_device_id: controls.deviceID.value,
volc_identity: { instance_id: controls.instanceID.value, product_key: controls.productKey.value, device_name: controls.volcDeviceName.value, hardware_id: controls.hardwareID.value },
conversation_id: state.platformSession?.conversation_id,
bot_id: state.platformSession?.runtime.bot_id,
model_mode: controls.modelMode.value,
transport: state.platformSession?.runtime.transport,
audio_playback: {
source_format: "pcm_s16le_16000_mono",
mode: "audio_worklet_ring_buffer",
prebuffer_ms: 120,
chunks: state.playbackChunks,
samples: state.playbackSamples,
underflows: state.playbackUnderflows,
},
provider_connection_ref: state.providerConnectionRef,
config_digest: state.platformSession?.runtime.config_digest,
prompt_bundle_version: state.platformSession?.session_config.prompt_bundle_version,
failure: state.failure,
checks: [...state.checks.values()],
turns: state.turns,
events: state.events,
};
}
async function exportReport() {
try {
const result = await window.deviceSimulator.saveHardwareReport(buildReport());
if (result.saved) log(`测试报告已保存:${result.path}`, "success");
} catch (error) { log(`导出测试报告失败:${errorMessage(error)}`, "error"); }
}
const unsubscribeProvider = window.deviceSimulator.onHardwareProviderEvent((event) => void handleProviderEvent(event));
controls.check.addEventListener("click", () => void configureAndProbe());
controls.start.addEventListener("click", () => void startHardwareConversation());
controls.stop.addEventListener("click", () => void stopHardwareConversation());
controls.sendText.addEventListener("click", () => void sendTextQuestion());
controls.textQuestion.addEventListener("keydown", (event) => { if (event.key === "Enter") void sendTextQuestion(); });
$("exportReport").addEventListener("click", () => void exportReport());
$("clearLog").addEventListener("click", () => $("eventLog").replaceChildren());
window.addEventListener("beforeunload", () => {
unsubscribeProvider();
stopTimers();
void stopAudio();
void window.deviceSimulator.closeHardwareAgent({ code: 1001, reason: "window closed" });
});
renderChecks();
setPhase("idle", "尚未检测");
void loadDefaults().catch((error) => log(`读取默认配置失败:${errorMessage(error)}`, "error"));
+25
View File
@@ -16,10 +16,15 @@ button:disabled { cursor: not-allowed; opacity: .45; }
.eyebrow { color: #c8652a; font-size: 12px; font-weight: 800; letter-spacing: .16em; margin: 0 0 9px; }
h1 { margin: 0; font-family: Georgia, "Microsoft YaHei UI", serif; font-size: 44px; letter-spacing: -.035em; }
h2 { margin: 0; font-size: 23px; }
h3 { margin: 24px 0 14px; color: #315966; font-size: 14px; }
.subtitle { max-width: 760px; color: #66808e; line-height: 1.8; margin: 12px 0 0; }
.overall { display: flex; align-items: center; min-width: 150px; justify-content: center; padding: 13px 18px; border-radius: 999px; background: rgba(255,255,255,.82); box-shadow: 0 12px 30px rgba(36,64,69,.1); color: #647a82; }
.overall.phase-ready, .overall.phase-active { color: #1a7d57; }
.overall.phase-error { color: #b63d2e; }
.overall.phase-completed { color: #1a7d57; }
.hero-actions { display: grid; justify-items: end; gap: 10px; }
.legacy-link { color: #617985; font-size: 12px; text-decoration: none; border-bottom: 1px solid #b9c8c7; padding-bottom: 2px; }
.legacy-link:hover { color: #c8652a; }
.status-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 16px; }
.status-card, .panel { background: rgba(255,255,255,.9); border: 1px solid rgba(23,61,69,.08); box-shadow: 0 14px 40px rgba(34,61,63,.08); }
@@ -77,7 +82,27 @@ input:focus, select:focus { border-color: #d6783f; box-shadow: 0 0 0 3px rgba(21
.transcript-turn strong { color: #315966; font-size: 12px; }
.transcript-turn span { color: #263f49; font-size: 14px; line-height: 1.55; }
.hardware-shell { width: min(1260px, calc(100vw - 48px)); }
.hardware-content-grid { grid-template-columns: 1.05fr 1.2fr; }
.hardware-call-panel { min-height: 720px; }
.hardware-session-info { grid-template-columns: 1fr 1fr; }
.text-question { display: grid; grid-template-columns: 1fr 150px; gap: 10px; margin-bottom: 22px; }
.text-question input { margin-top: 0; }
.text-question .secondary { padding: 10px; }
.flow-panel { margin-top: 16px; }
.flow-panel .panel-heading { margin-bottom: 14px; }
.flow-checks { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; list-style: none; padding: 0; margin: 0; }
.flow-check { display: grid; grid-template-columns: 28px 1fr; grid-template-rows: auto auto; column-gap: 10px; align-items: center; padding: 13px; border: 1px solid #e3ebe8; border-radius: 14px; background: #fafcfb; }
.flow-check i { grid-row: 1 / span 2; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; font-style: normal; font-weight: 800; color: #809399; background: #e8efed; }
.flow-check span { min-width: 0; color: #31505e; font-size: 13px; font-weight: 700; }
.flow-check small { min-width: 0; margin-top: 4px; color: #8b9ba1; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.flow-check--passed { border-color: #cce5d7; background: #f4faf6; }
.flow-check--passed i { color: white; background: #2f8d67; }
.flow-check--failed { border-color: #edcbc5; background: #fff7f5; }
.flow-check--failed i { color: white; background: #b94c3d; }
@media (max-width: 1050px) {
.status-grid { grid-template-columns: repeat(2, 1fr); }
.content-grid { grid-template-columns: 1fr; }
.flow-checks { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@@ -0,0 +1,108 @@
const crypto = require("node:crypto");
const { WebSocketServer } = require("ws");
const { websocketSignature } = require("../electron/hardware-agent-client.cjs");
const port = Number(process.env.MOCK_HARDWARE_GATEWAY_PORT || 19090);
const expectedSecret = process.env.MOCK_VOLC_DEVICE_SECRET || "mock-volc-device-secret-at-least-32-characters";
const server = new WebSocketServer({ host: "127.0.0.1", port, maxPayload: 1024 * 1024 });
function send(socket, value) { socket.send(JSON.stringify(value)); }
async function requestPlatformStop(conversationID) {
const baseURL = String(process.env.MOCK_PLATFORM_API_URL || "").replace(/\/$/, "");
const deviceID = String(process.env.MOCK_PLATFORM_DEVICE_ID || "");
const secret = String(process.env.MOCK_PLATFORM_DEVICE_SECRET || "");
if (!baseURL || !deviceID || !secret || !conversationID) return;
const requestPath = `/device/v2/conversations/${encodeURIComponent(conversationID)}/stop`;
const body = JSON.stringify({ reason: "windows_e2e_platform_stop", device_time: new Date().toISOString(), last_heartbeat_sequence: 1, rtc_left: true, recording: { near_segments: 0, far_segments: 0 } });
const timestamp = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString("hex");
const digest = crypto.createHash("sha256").update(body).digest("hex");
const canonical = ["POST", requestPath, timestamp, nonce, digest].join("\n");
const signature = crypto.createHmac("sha256", secret).update(canonical).digest("base64url");
const response = await fetch(`${baseURL}${requestPath}`, {
method: "POST",
headers: {
Accept: "application/json", "Content-Type": "application/json", "Idempotency-Key": `windows-platform-stop-${crypto.randomUUID()}`,
"X-Device-Id": deviceID, "X-Device-Timestamp": timestamp, "X-Device-Nonce": nonce,
"X-Device-Content-SHA256": digest, "X-Device-Signature": signature,
},
body,
});
if (!response.ok) throw new Error(`platform stop HTTP ${response.status}: ${await response.text()}`);
process.stdout.write(`${JSON.stringify({ type: "platform_stop_requested", conversation_id: conversationID })}\n`);
}
function tonePCMBase64() {
const samples = 2400;
const bytes = Buffer.alloc(samples * 2);
for (let index = 0; index < samples; index += 1) {
const sample = Math.round(Math.sin((index / 16000) * Math.PI * 2 * 440) * 1800);
bytes.writeInt16LE(sample, index * 2);
}
return bytes.toString("base64");
}
server.on("listening", () => process.stdout.write(`${JSON.stringify({ type: "listening", port })}\n`));
server.on("connection", (socket, request) => {
const header = (name) => String(request.headers[name.toLowerCase()] || "");
const expectedSignature = websocketSignature({
authType: 1,
deviceName: header("x-device-name"),
randomNumber: Number(header("x-random-num")),
productKey: header("x-product-key"),
timestamp: Number(header("x-timestamp")),
instanceID: header("x-instance-id"),
}, expectedSecret);
if (!crypto.timingSafeEqual(Buffer.from(header("x-signature")), Buffer.from(expectedSignature))) {
socket.close(1008, "invalid signature");
return;
}
const target = new URL(request.url, `ws://127.0.0.1:${port}`);
if (!target.searchParams.get("bot") || target.searchParams.get("wait_for_session_update") !== "true") {
socket.close(1008, "invalid query");
return;
}
process.stdout.write(`${JSON.stringify({ type: "authenticated", device: header("x-device-name"), bot: target.searchParams.get("bot") })}\n`);
send(socket, { type: "session.created", event_id: "mock-created", session: { id: "mock-provider-session-windows", model: "doubao", input_audio_format: "pcm16", output_audio_format: "pcm16", turn_detection: { type: "server_vad" } } });
let replied = false;
let dialogID = "";
socket.on("message", (raw) => {
const event = JSON.parse(raw.toString("utf8"));
if (event.type === "session.update") {
const llmPrompt = event.session?.config?.LLMConfig?.SystemMessages?.[0];
const s2sDialog = event.session?.config?.S2SConfig?.ProviderParams?.dialog;
const sessionDialogID = String(s2sDialog?.dialog_id || (llmPrompt ? llmPrompt.match(/本次对话标识:([^\n]+)/)?.[1] : "") || "").trim();
const frozenPrompt = String(llmPrompt || s2sDialog?.system_role || "").trim();
if (event.session?.object !== "realtime.session" || event.session?.config?.ASRConfig?.TurnDetectionMode !== 0 || !sessionDialogID || !frozenPrompt) {
send(socket, { type: "error", event_id: "mock-error", error: { code: "INVALID_SESSION_CONFIG", message: "frozen context missing" } });
return;
}
dialogID = sessionDialogID;
send(socket, { type: "session.updated", event_id: "mock-updated", session: { ...event.session, id: "mock-provider-session-windows" } });
process.stdout.write(`${JSON.stringify({ type: "dynamic_config_applied", dialog_id: dialogID })}\n`);
return;
}
if (event.type === "input_audio_buffer.append" && !replied) {
replied = true;
send(socket, { type: "input_audio_buffer.speech_started", event_id: "mock-speech-start", audio_start_ms: 0 });
send(socket, { type: "input_audio_buffer.speech_stopped", event_id: "mock-speech-stop", audio_end_ms: 800 });
send(socket, { type: "conversation.item.input_audio_transcription.completed", event_id: "mock-user-final", item_id: "mock-user-item", transcript: "你好,请介绍一下今天的安排。" });
send(socket, { type: "response.created", event_id: "mock-response-created", response: { id: "mock-response-1", status: "in_progress" } });
send(socket, { type: "response.audio_transcript.delta", event_id: "mock-agent-delta", response_id: "mock-response-1", delta: "您好,今天可以先聊聊您最想做的事情。" });
send(socket, { type: "response.audio.delta", event_id: "mock-audio-delta", response_id: "mock-response-1", delta: tonePCMBase64() });
send(socket, { type: "response.audio_transcript.done", event_id: "mock-agent-final", response_id: "mock-response-1", transcript: "您好,今天可以先聊聊您最想做的事情。" });
send(socket, { type: "response.audio.done", event_id: "mock-audio-done", response_id: "mock-response-1" });
send(socket, { type: "response.done", event_id: "mock-response-done", response: { id: "mock-response-1", status: "completed", output: [] } });
process.stdout.write(`${JSON.stringify({ type: "round_completed" })}\n`);
if (process.env.MOCK_REQUEST_PLATFORM_STOP === "1") {
setTimeout(() => void requestPlatformStop(dialogID).catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
}), 250);
}
}
});
socket.on("close", (code) => process.stdout.write(`${JSON.stringify({ type: "closed", code })}\n`));
});
function shutdown() { server.close(() => process.exit(0)); }
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
@@ -0,0 +1,120 @@
param(
[ValidateSet('llm', 's2s')]
[string]$ModelMode = 'llm',
[string]$ExecutablePath = ''
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$simulatorRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
$projectRoot = (Resolve-Path -LiteralPath (Join-Path $simulatorRoot '..')).Path
$devicePublicID = '10000000-0000-0000-0000-000000000511'
$platformSecret = 'local-simulated-device-secret-32-bytes-minimum'
$volcSecret = 'mock-volc-device-secret-at-least-32-characters'
$mockPort = 19090
$runID = [Guid]::NewGuid().ToString('N')
$resultPath = Join-Path $env:TEMP "hardware-agent-e2e-$runID.json"
$mockOutput = Join-Path $env:TEMP "hardware-agent-gateway-$runID.log"
$mockError = Join-Path $env:TEMP "hardware-agent-gateway-$runID.err.log"
$mockProcess = $null
$conversationID = $null
$deviceInternalID = $null
Set-Location -LiteralPath $projectRoot
try {
$ready = Invoke-WebRequest -UseBasicParsing -Uri 'http://localhost:58080/health/ready' -TimeoutSec 10
if ($ready.StatusCode -ne 200) { throw 'Local API is not ready' }
$deviceInternalID = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT id FROM device WHERE public_id='$devicePublicID'::uuid" | Out-String).Trim()
if ($deviceInternalID -notmatch '^[0-9a-fA-F-]{36}$') { throw 'Local simulator device is missing' }
$bindingCount = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT count(*) FROM hardware_agent_device_binding WHERE device_id='$deviceInternalID'::uuid" | Out-String).Trim()
if ($bindingCount -ne '0') { throw 'Simulator device already has a hardware binding; refusing to overwrite it' }
"UPDATE device_twin SET desired_config=desired_config||'{`"voice_runtime`":`"volc_hardware_agent`",`"hardware_agent_transport`":`"low_load`",`"minimum_sdk_version`":`"1.0.0`"}'::jsonb WHERE device_id='$deviceInternalID'::uuid; INSERT INTO hardware_agent_device_binding(tenant_id,device_id,bot_id,transport,minimum_sdk_version,status) SELECT tenant_id,id,'mock-windows-hardware-bot','low_load','1.0.0','ACTIVE' FROM device WHERE id='$deviceInternalID'::uuid;" | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
$mockScript = Join-Path $simulatorRoot 'test\mock-hardware-gateway.cjs'
$env:MOCK_HARDWARE_GATEWAY_PORT = [string]$mockPort
$env:MOCK_VOLC_DEVICE_SECRET = $volcSecret
$env:MOCK_REQUEST_PLATFORM_STOP = '1'
$env:MOCK_PLATFORM_API_URL = 'http://localhost:58080'
$env:MOCK_PLATFORM_DEVICE_ID = $devicePublicID
$env:MOCK_PLATFORM_DEVICE_SECRET = $platformSecret
$mockProcess = Start-Process -FilePath 'node' -ArgumentList @($mockScript) -PassThru -WindowStyle Hidden -RedirectStandardOutput $mockOutput -RedirectStandardError $mockError
$deadline = [DateTime]::UtcNow.AddSeconds(10)
do {
Start-Sleep -Milliseconds 150
$listening = Test-NetConnection 127.0.0.1 -Port $mockPort -InformationLevel Quiet -WarningAction SilentlyContinue
if ($mockProcess.HasExited) { throw "Mock gateway exited: $(Get-Content -LiteralPath $mockError -Raw -ErrorAction SilentlyContinue)" }
} until ($listening -or [DateTime]::UtcNow -gt $deadline)
if (-not $listening) { throw 'Mock hardware gateway did not start' }
$env:SIMULATOR_AUTO_TEST = '1'
$env:SIMULATOR_AUTO_WAIT_FOR_PLATFORM_STOP = '1'
$env:SIMULATOR_AUTO_RESULT_PATH = $resultPath
$env:SIMULATOR_DEFAULT_API_URL = 'http://localhost:58080'
$env:SIMULATOR_DEFAULT_DEVICE_ID = $devicePublicID
$env:SIMULATOR_DEFAULT_DEVICE_SECRET = $platformSecret
$env:SIMULATOR_VOLC_INSTANCE_ID = 'mock-instance'
$env:SIMULATOR_VOLC_PRODUCT_KEY = 'mock-product'
$env:SIMULATOR_VOLC_DEVICE_NAME = 'windows-e2e-device'
$env:SIMULATOR_VOLC_HARDWARE_ID = 'windows-e2e-hardware'
$env:SIMULATOR_VOLC_DEVICE_SECRET = $volcSecret
$env:SIMULATOR_VOLC_MODEL_MODE = $ModelMode
$env:SIMULATOR_HARDWARE_GATEWAY_URL = "ws://127.0.0.1:$mockPort/v1/realtime"
Set-Location -LiteralPath $simulatorRoot
if ($ExecutablePath) {
$resolvedExecutable = (Resolve-Path -LiteralPath $ExecutablePath).Path
$electron = Start-Process -FilePath $resolvedExecutable -WorkingDirectory (Split-Path -Parent $resolvedExecutable) -PassThru -Wait
} else {
$electron = Start-Process -FilePath (Join-Path $simulatorRoot 'node_modules\electron\dist\electron.exe') -ArgumentList @('.') -WorkingDirectory $simulatorRoot -PassThru -Wait
}
if ($electron.ExitCode -ne 0) { throw "Electron E2E process failed with exit code $($electron.ExitCode)" }
if (-not (Test-Path -LiteralPath $resultPath -PathType Leaf)) { throw 'Electron E2E result file is missing' }
$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
if ($result.result -ne 'PASS' -or $result.phase -ne '已完成') { throw "Electron E2E failed: $($result | ConvertTo-Json -Depth 8 -Compress)" }
if ($result.model_mode -ne $ModelMode) { throw "Electron E2E used model mode '$($result.model_mode)' instead of '$ModelMode'" }
$conversationID = [string]$result.conversation_id
if ($conversationID -notmatch '^[0-9a-fA-F-]{36}$') { throw 'Electron E2E did not return a conversation ID' }
$databaseState = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -F '|' -c "SELECT c.runtime_type||'|'||c.status||'|'||hs.status||'|'||hs.sdk_version||'|'||(hs.dynamic_config_applied_at IS NOT NULL)::text||'|'||count(DISTINCT ct.id)||'|'||(SELECT count(*) FROM web_event we WHERE we.resource_public_id=c.public_id AND we.event_type IN('conversation.started','conversation.completed')) FROM conversation c JOIN hardware_agent_session hs ON hs.conversation_id=c.id LEFT JOIN conversation_turn ct ON ct.conversation_id=c.id WHERE c.public_id='$conversationID'::uuid GROUP BY c.runtime_type,c.status,hs.status,hs.sdk_version,hs.dynamic_config_applied_at,c.public_id" | Out-String).Trim()
if ($databaseState -ne 'volc_hardware_agent|COMPLETED|COMPLETED|1.1.0-windows-standard-protocol|true|2|2') { throw "Unexpected platform state: $databaseState" }
$gatewayEvents = Get-Content -LiteralPath $mockOutput -Raw
if ($gatewayEvents -notmatch 'dynamic_config_applied' -or $gatewayEvents -notmatch 'round_completed' -or $gatewayEvents -notmatch 'platform_stop_requested') { throw "Mock gateway did not observe config, media, and platform-stop flow: $gatewayEvents" }
[ordered]@{
result = 'PASS'
conversation_id = $conversationID
platform_state = $databaseState
transcript_turns = $result.turns.Count
checks_passed = @($result.checks | Where-Object status -eq 'passed').Count
model_mode = $result.model_mode
mock_gateway_log = $mockOutput
} | ConvertTo-Json -Compress
}
finally {
Set-Location -LiteralPath $projectRoot
if ($mockProcess -and -not $mockProcess.HasExited) { Stop-Process -Id $mockProcess.Id -Force -ErrorAction SilentlyContinue }
if ($conversationID -match '^[0-9a-fA-F-]{36}$') {
@"
BEGIN;
CREATE TEMP TABLE hc AS SELECT id,context_snapshot_id,consent_snapshot_id FROM conversation WHERE public_id='$conversationID'::uuid;
DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM hc);
DELETE FROM web_event WHERE resource_public_id='$conversationID'::uuid;
DELETE FROM idempotency_record WHERE resource_id IN(SELECT id FROM hc);
DELETE FROM conversation_turn WHERE conversation_id IN(SELECT id FROM hc);
DELETE FROM conversation_memory_use WHERE conversation_id IN(SELECT id FROM hc);
DELETE FROM memory_tool_call WHERE conversation_id IN(SELECT id FROM hc);
DELETE FROM hardware_agent_event WHERE conversation_id IN(SELECT id FROM hc);
DELETE FROM hardware_agent_session WHERE conversation_id IN(SELECT id FROM hc);
DELETE FROM conversation_usage_budget WHERE conversation_id IN(SELECT id FROM hc);
DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM hc);
DELETE FROM conversation_state_history WHERE conversation_id IN(SELECT id FROM hc);
DELETE FROM conversation WHERE id IN(SELECT id FROM hc);
DELETE FROM context_snapshot WHERE id IN(SELECT context_snapshot_id FROM hc);
DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM hc);
COMMIT;
"@ | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
}
if ($deviceInternalID -match '^[0-9a-fA-F-]{36}$') {
"DELETE FROM hardware_agent_device_binding WHERE device_id='$deviceInternalID'::uuid; UPDATE device_twin SET desired_config=desired_config-'voice_runtime'-'hardware_agent_transport'-'minimum_sdk_version' WHERE device_id='$deviceInternalID'::uuid;" | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
}
@('MOCK_HARDWARE_GATEWAY_PORT','MOCK_VOLC_DEVICE_SECRET','MOCK_REQUEST_PLATFORM_STOP','MOCK_PLATFORM_API_URL','MOCK_PLATFORM_DEVICE_ID','MOCK_PLATFORM_DEVICE_SECRET','SIMULATOR_AUTO_TEST','SIMULATOR_AUTO_WAIT_FOR_PLATFORM_STOP','SIMULATOR_AUTO_RESULT_PATH','SIMULATOR_DEFAULT_API_URL','SIMULATOR_DEFAULT_DEVICE_ID','SIMULATOR_DEFAULT_DEVICE_SECRET','SIMULATOR_VOLC_INSTANCE_ID','SIMULATOR_VOLC_PRODUCT_KEY','SIMULATOR_VOLC_DEVICE_NAME','SIMULATOR_VOLC_HARDWARE_ID','SIMULATOR_VOLC_DEVICE_SECRET','SIMULATOR_VOLC_MODEL_MODE','SIMULATOR_HARDWARE_GATEWAY_URL') | ForEach-Object { Remove-Item "Env:$_" -ErrorAction SilentlyContinue }
Remove-Item -LiteralPath $resultPath,$mockOutput,$mockError -Force -ErrorAction SilentlyContinue
}
+7
View File
@@ -1,4 +1,5 @@
import { defineConfig } from "vite";
import { resolve } from "node:path";
export default defineConfig({
base: "./",
@@ -6,5 +7,11 @@ export default defineConfig({
outDir: "dist",
emptyOutDir: true,
sourcemap: true,
rollupOptions: {
input: {
hardware: resolve(__dirname, "index.html"),
legacy: resolve(__dirname, "legacy.html"),
},
},
},
});
@@ -1,21 +1,24 @@
云端语音测试设备使用说明
云端硬件对话智能体测试程序使用说明
1. 将整个 ZIP 解压到普通文件夹,不要只从压缩包内直接运行。
2. 双击“启动云端测试设备.cmd”,不要直接双击 EXE。
3. 首次运行时允许 Windows 麦克风访问。
4. 点击“检测平台和音频设备”,检测通过后即可开始语音聊天。
1. 将 ZIP 完整解压到普通文件夹,不要直接在压缩包内运行。
2. 双击“启动云端测试设备.cmd”,或运行 hardware-agent-tester-0.3.2-windows-x64.exe。
3. 首次运行允许 Windows 麦克风访问。
4. 填写平台设备信息和 Hardware ID;火山 Instance ID、Product Key、Device Name 由平台自动下发。
5. Product Secret 只配置在平台密钥服务中,测试程序不会要求输入;平台代设备注册后只下发本设备的一机一密。
6. 根据火山控制台中的 Bot 类型选择“通用 LLM”或“豆包端到端语音”。选错会导致 session.update 校验失败。
7. 点击“检测、鉴权并准备设备”,13项验收区开始记录证据。
8. 检测通过后点击“开始硬件智能体通话”。只有收到火山 session.updated 且平台确认 ACTIVE 后才会开放麦克风。
9. 完成双方对话后点击结束;也可以从平台停止通话,程序会在 session_updated 回执中收到 stop_session 并自动结束。
10. 点击“导出测试报告”,将 JSON 报告交给开发人员验收。
云端后台:https://121.41.46.238
测试设备:10000000-0000-0000-0000-000000000511
平台维护中心必须把本设备设置为 volc_hardware_agent + low_load,并绑定与火山产品关联的真实 Bot ID。公网平台尚未部署 v3 路由时会返回 404,此时不能进行真实测试。
网络需要允许 HTTPS 443、WSS 以及火山 RTC 所需的出站网络连接。
网络需要允许 HTTPS 443 和 WSS 到:
- 平台服务器
- ai-gateway.vei.volces.com
如果检测平台时提示 RTC 网络检测失败,或入房超过 15 秒:
- 关闭 VPN、系统代理和网络加速器;
- 在 Windows 防火墙/杀毒软件中允许本程序联网;
- 优先改用手机热点复测,以排除公司、校园或酒店网络限制;
- 需要放行 TCP 443 到火山 RTC Web SDK 4.69.0 使用的 *.rtc.volcvideo.com 和 *.volcvideos.com。
`iot-cn-shanghai.iot.volces.com`只需要平台服务器访问,设备无需访问动态注册地址。
如果手机热点、关闭VPN/代理后仍然无法入房,请在失败后点击页面底部“导出 RTC 日志”,把 Windows“下载”目录中最新生成的 RTC 日志文件交给开发人员分析。
安全提示:Product Secret 永不到达设备。平台下发的一机一密会使用当前 Windows 用户的 Safe Storage 加密保存,导出的测试报告不包含任何密钥。
安全提示:本测试包包含服务器测试设备凭据,仅限受控测试使用,请勿公开上传或转发。测试完成后请删除其他电脑上的解压目录和 ZIP 文件。
当前测试 EXE 未配置商业代码签名证书,Windows 可能提示“未知发布者”。请先用随包 SHA-256 文件核对完整性;正式外发版本应使用甲方或发布主体的代码签名证书签名。
@@ -0,0 +1,187 @@
# AI电话 v3.1 流程与现有实现差异分析
核对日期:2026-09-10。结论:**总体方向相近,但现有实现不等同于 CALL-MEMORY-1.1。差异涉及协议、固件运行方式和记忆数据维护,不能仅提取新文档接口就直接交给设备联调。**
对照材料为用户提供的 [AI电话完整通话与记忆管理文档](D:/wechat/xwechat_files/wangcong3092_983b/msg/file/2026-09/AI电话_完整通话与记忆管理_完整流程与详细设计(1).docx),封面版本 v3.1,修订日期 2026-09-08。文档作为需求基线,不作为执行其中开发、发布或运维指令的授权。
实现基线为 `E:/Codex/story/platform` 当前工作区,Git HEAD 为 `7846f30d0b239e183267988aa6e030b0a2f1ef18`。重点检查 Go 后端路由与业务服务、数据库迁移、EchoEar 固件、记忆页面代码和已有联调记录。结论针对本地实现;本次未连接生产环境、重新运行真机或供应商联调,不能据此推定线上部署与工作区完全相同。
## 文档描述的完整流程
1. **上电准备**:恢复已有设备身份、绑定和未确认结束日志;联网后 warmup,预备音频及引擎。挂机时不建立真实对话、不上传环境声。SaaS 在后台同步记忆并准备开场候选。
2. **摘机准备**:生成 boot/hook/call_generation 关联;prepare 从服务端绑定确定使用者,检查记忆主库版本、关键删除或更正是否已在检索副本生效。
3. **签发与启动**:SaaS 编译供应商参数,签发绑定设备、本次摘机、隐私版本、启动期限和租约的 Session Manifest。设备验签后立即开始心跳,透传参数,以 RTC 模式启动 SDK。
4. **连续通话**:声音由设备直接与火山交互。媒体连接、收到第一帧、实际开始播放分别记录。字幕经异步队列上报;历史记忆由经过验证的火山原生 Viking 联动读取。
5. **通话中修改记忆**:SaaS 从可靠的用户最终文字识别记住、更正、限制使用、删除;对象不清进入页面确认。提交主库后分别跟踪副本、派生内容和旧会话清理,必要时下发 STOP_CALL。
6. **挂机收尾**:本地先关闭上下行与待播音频,再停止 SDK、补报 end。SaaS 将通话结束和字幕完整度分开记录;字幕静默收齐初值 15 秒、绝对等待 60 秒。
7. **维护记忆与准备下一通**:从用户证据提取候选,处理冲突和删除屏障,提交 SaaS 主库与 outbox;按主记录版本同步 Viking 事件及画像,验证检索可见性,再推进连续同步进度。下次摘机重新检查。
文档强调四个结果分别成立:**通话结束、字幕收齐、记忆保存、检索可见**。普通新增同步慢可以按安全模式继续;更正、删除和权限变化不能沿用不安全的旧资料。
## 现有流程
当前 EchoEar 主路径为:
```text
上电恢复平台及火山凭据,尝试补报上次未结束会话,初始化音频与引擎
→ 触摸或唤醒触发对话
→ bootstrap + config + POST /device/v3/agent-sessions
→ 后端冻结使用者、授权和上下文,返回 runtime + session_config
→ 固件组装 session.update,使用 VOLC_MODE_WS 启动
→ session.created/updated 后上报 session_started
→ 通过 session_updated 周期续期,异步上传最终字幕
→ volc_stop + session_ended/session_failed,清理播放器
→ SaaS 摘要/故事/候选处理
→ 另一路将通话文字 AddSession 到 Viking 自动抽取
```
入口依据:[固件创建会话](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:558)、[SDK 启动与退出](E:/Codex/story/platform/firmware/echoear-platform-agent/components/application/service/conv_service/src/volc_conv.c:389)、[后台任务装配](E:/Codex/story/platform/internal/app/app.go:427)。
## 可以复用的实现
| 能力 | 已有实现 | 对齐程度 |
|---|---|---|
| 设备认证与服务端确定使用者 | HMAC、防重放、设备有效绑定;会话保存 subject_id 和授权快照 | 方向一致,认证报文及绑定代数协议不同 |
| 音频与业务链路分开 | 设备直接调用火山 SDK,平台接收控制信息与文字 | 方向一致,EchoEar 实际使用 WS |
| 共享助手和用户记忆范围 | Bot 配置、稳定助手标识、租户与使用者派生的 Viking user_id | 已有基础,不能替代供应商侧防篡改授权验证 |
| 异步文字上报 | SDK 回调投递内存队列,业务任务发 HTTPS,后端事务保存并去重 | 主思路一致,缺修订、缺口和持久重试协议 |
| 结束恢复 | NVS 保存待结束会话,重启补报;后端支持结束状态及迟到最终文字 | 已有基础,缺未取得 SID 的取消及独立远端停止证明 |
| 后台摘要与人工记忆维护 | 用户文字来源、摘要、故事、候选、人工确认、记忆版本、编辑/停用/删除、索引 outbox | 已有基础,与 Viking 自动抽取副本未统一 |
证据:[会话归属与上下文快照](E:/Codex/story/platform/internal/orchestration/repository.go:293)、[字幕保存与原会话归属](E:/Codex/story/platform/internal/orchestration/transcript_batch.go:127)、[仅用户文字参与抽取](E:/Codex/story/platform/internal/extraction/processor.go:94)、[人工记忆维护](E:/Codex/story/platform/internal/saas/content_service.go:568)。
## 关键差异及影响
### 1 设备接口与认证不兼容
文档附件 B 定义七个 `POST /api/v1/device/calls/*` 接口;当前注册的是 `/device/v2/*` 和 `/device/v3/agent-sessions*`。这七个新路径没有注册。
认证也不只是换路径:
| 项目 | v3.1 文档 | 当前实现 |
|---|---|---|
| 时间 | `X-Timestamp-Ms`,Unix 毫秒 | `X-Device-Timestamp`,RFC3339 |
| 签名入口 | `Authorization: Device-HMAC-SHA256 ...` | `X-Device-Signature` |
| 签名内容 | DEVREQ1 开头的九行,含设备、密钥版本、请求 ID | 方法、含查询串的 RequestURI、时间、nonce、body hash 五行 |
| 签名编码 | 小写十六进制 | 无填充 Base64url |
| 响应 | `ok/code/request_id/server_time_ms/data` | `request_id/data/error` |
现有 HMAC 本身已经实现,但不兼容文档的线协议。直接按文档开发新固件会遇到路由或验签失败。
证据:[设备路由](E:/Codex/story/platform/internal/httpapi/router.go:308)、[认证头及验签](E:/Codex/story/platform/internal/deviceauth/verifier.go:22)、[当前签名算法](E:/Codex/story/platform/internal/deviceauth/verifier.go:142)、[响应封装](E:/Codex/story/platform/internal/httpapi/response.go:8)。
### 2 固件实际使用 WS 而文档固定 RTC
文档附件 C 要求 `mode=RTC`,映射 `VOLC_MODE_RTC`。当前 EchoEar 能力声明只有 `low_load`,实际 `volc_start` 参数写的是 `VOLC_MODE_WS`。
后端保留 high_quality/low_load 两类能力,并不等于当前固件已经按 RTC 流程运行。切换需要核对 RTC 参数通道、字幕格式、首音频、打断和 stop 行为,不能只改枚举后视为完成。
证据:[固件能力声明](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:569)、[实际 SDK 模式](E:/Codex/story/platform/firmware/echoear-platform-agent/components/application/service/conv_service/src/volc_conv.c:439)、[后端传输校验](E:/Codex/story/platform/internal/orchestration/repository.go:614)。
### 3 没有新版会话清单及摘机取消机制
文档要求 `boot_id + hook_event_id` 唯一业务键,独立 `call_generation`、`binding_generation`、`media_attempt_id`,以及 `manifest_b64/manifest_mac`。即使 end 先于 prepare 到达,也要保存取消记录,阻止晚到 prepare 启动。
当前创建请求使用 `Idempotency-Key`,返回 `session_nonce/config_digest/runtime/session_config`;具有请求幂等和配置摘要,但没有上述清单结构、按摘机取消、清单撤销及代数校验。当前摘要也不是文档要求的清单 HMAC。尚未取得 conversation_id 时没有等价的结束入口。
证据:[请求与响应结构](E:/Codex/story/platform/internal/orchestration/types.go:95)、[创建幂等入口](E:/Codex/story/platform/internal/orchestration/hardware_agent.go:31)、[固件响应处理](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:584)。
### 4 心跳起点与本地租约停止不同
文档要求验清单后立即开始心跳,与 SDK 启动并行;默认周期 5 秒、租约不超过 45 秒。失联时固件依本地保守截止关闭音频;迟到许可不能复活会话。
当前固件在上报 session_started 后设置下一次心跳,通过 `session_updated` 续期。默认周期为 15 秒;后端租约按 `HeartbeatInterval × 5` 计算,默认对应 75 秒。心跳网络失败时固件记录错误并再次尝试,没有看到该路径维护本地授权截止或到期静音。
这意味着现有服务端超时回收不能等同于文档要求的设备本地有界停止。
证据:[固件续期逻辑](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:693)、[启动成功后才轮询](E:/Codex/story/platform/firmware/echoear-platform-agent/components/application/service/conv_service/src/volc_conv.c:459)、[后端租约](E:/Codex/story/platform/internal/orchestration/hardware_agent.go:205)、[默认周期](E:/Codex/story/platform/internal/config/config.go:479)。
### 5 供应商参数由固件拼装且开场配置未形成文档流程
文档要求 SaaS 基于已验证 provider_profile 编译完整 `vendor_params_json`,设备只透传;热上下文与欢迎语分别产生,且附事实依赖版本。
目前后端返回 authorized_context 等结构化字段,固件自行拼接固定话术、LLMConfig、S2SConfig、MemoryConfig 与回调参数。平台虽然有资料、最近摘要、open_topic 和上下文快照,但未形成独立欢迎语、开场候选依赖失效及 provider_profile 能力证据驱动的编译流程。
当前上下文对部分字段按字符数裁剪,也未体现文档要求的按完整事实删减并保留否定、日期和条件的规则。
证据:[端侧参数组装](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:457)、[上下文与摘要选择](E:/Codex/story/platform/internal/orchestration/repository.go:388)、[开场续聊与裁剪](E:/Codex/story/platform/internal/orchestration/companion_context.go:19)。
### 6 Viking 写入路线与文档有实质冲突
文档第 11 章要求 SaaS 是记忆事实维护方,Viking 保存已核验主库记录的检索副本。默认不让两边根据原始字幕各自抽取和维护同一份记忆。
当前工作器在内容抽取任务中先排入独立 Viking 同步任务;Viking 服务读取 conversation_turn 中的用户和 AI 最终文字,再调用 `/api/memory/session/add`,并设置 `extract_memory_type` 让 Viking 抽取事件及画像。这条任务不以 SaaS curated_memory 提交完成为前置条件。
因此当前是“平台内容整理 + Viking 会话自动抽取”两条路径。它不能保证平台更正、停用或删除某条人工记忆后,Viking 独立生成的事件和画像同步发生相同变化。
证据:[工作器装配](E:/Codex/story/platform/internal/app/app.go:427)、[同步数据取自通话文字](E:/Codex/story/platform/internal/vikingmemory/service.go:175)、[AddSession 及抽取类型](E:/Codex/story/platform/internal/vikingmemory/client.go:127)。
### 7 单条记忆修改删除未贯通 Viking 及旧会话
当前平台已经有 `UpdateMemory/RequestMemoryDeletion`、版本比较、本地索引失效和删除任务。另有 `providermemory` 适配器,调用自定义 `/v1/memories:upsert`,它与 `vikingmemory` 是独立模块,不能把前者的完成视为 Viking 原生事件/画像已清理。
已有接入说明明确指出:单条本地 curated memory 删除不会删除独立抽取的 Viking 事件/画像,统一管理界面尚未实现。文档要求的 source_suppression、单条投影映射、派生摘要清理及防旧源重抽取尚未贯通。
此外,本次在相关源码和迁移中没有找到 `privacy_epoch`、已签发清单撤销目标、对应 `STOP_CALL` 及分阶段本地静音/SDK 停止回执。已有通话停止和授权撤回能力不能替代文档附件 L 的完整机制。
证据:[单条删除入口](E:/Codex/story/platform/internal/saas/content_service.go:705)、[本地删除处理](E:/Codex/story/platform/internal/memorylifecycle/delete_processor.go:32)、[独立 provider 适配器](E:/Codex/story/platform/internal/providermemory/client.go:59)、[Viking 已知限制](E:/Codex/story/platform/docs/viking-memory-integration.md:23)。
### 8 没有通话前同步水位与关键变更拦截
文档要求用户级 memory_revision、viking_visible_revision、关键积压和 EMPTY/IN_SYNC/LAGGING_NORMAL/LAGGING_CRITICAL 等状态。供应商写入成功后还要验证检索可见,且中间版本失败不能被后续成功版本跳过。
当前 NativeAccessForDevice 主要检查当前绑定和授权,再返回用户过滤配置;AddSession 成功后记录审计摘要。未看到针对主库记忆版本的连续可见进度、关键删除拦截、NATIVE_VIKING/HOT_ONLY/OFF 模式及关闭旧记忆的验证机制。Viking 获取配置失败当前可仅记录警告后继续创建,这与按关键积压判断是否允许启动不同。
证据:[创建时 Viking 处理](E:/Codex/story/platform/internal/orchestration/hardware_agent.go:46)、[原生范围配置](E:/Codex/story/platform/internal/vikingmemory/service.go:84)、[写入与审计](E:/Codex/story/platform/internal/vikingmemory/service.go:208)。
### 9 字幕可靠性及结束状态未达到新版要求
已实现最终字幕上报、事务落库、重复检测和结束后补入,但当前 turn_key/sequence_no 的同一条内容变化会冲突;没有文档要求的 utterance_id/source_revision 修订和分片结构。
固件当前使用 8 项内存消息队列,满时记日志;上报失败后释放消息,未看到对应的持久重试与 GAP_DECLARED。端侧上报把 interrupted 固定为 false,不能据此证明 AI 的整句话已播放。
后端 session_ended 会直接完成业务状态并排入抽取,没有独立 DRAINING/COMPLETE/PARTIAL 状态及文档的 15/60 秒收齐窗口。固件退出时先设置 is_stop,但播放器停止在 SDK stop 和网络结束上报之后,未实现文档要求的先立即清空待播内容、分别报告停止证据的顺序。
证据:[字幕结构与冲突规则](E:/Codex/story/platform/internal/orchestration/transcript_batch.go:24)、[端侧上报与失败处理](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:713)、[队列及满队列处理](E:/Codex/story/platform/firmware/echoear-platform-agent/components/application/service/conv_service/src/volc_conv.c:330)、[结束与抽取](E:/Codex/story/platform/internal/orchestration/hardware_agent.go:214)、[退出顺序](E:/Codex/story/platform/firmware/echoear-platform-agent/components/application/service/conv_service/src/volc_conv.c:483)。
### 10 语音记忆变更确认与事项进度需要补充
当前人工记忆编辑能力不等同于文档第 8 章:尚未找到从用户最终文字生成 intent/mutation、来源动作去重、NEEDS_CONFIRMATION、resolve/confirm 冻结方案、六阶段处理进度、PLAY_LOCAL_NOTICE 的整套实现。
现有 open_topic 是摘要中的续聊线索;没有文档的稳定 OPEN_ITEM 标识、OPEN/COMPLETED/CANCELLED/UNKNOWN 及计划修订状态机。因此“第一通提计划、第二通报告完成、第三通不再追问”的结果尚不能由当前数据流程保证。
证据:[现有记忆页面 API](E:/Codex/story/platform/internal/httpapi/router.go:267)、[现有摘要与 open_topic](E:/Codex/story/platform/internal/extraction/processor.go:284)、[续聊线索选择](E:/Codex/story/platform/internal/orchestration/companion_context.go:70)。
## 设备接口对应关系
下表只用于说明差异,**不是 CALL-MEMORY-1.1 已实现接口文档**。文档七个接口均为 POST,统一前缀 `/api/v1/device/calls/`。当前对应功能不代表字段、鉴权、幂等和状态语义兼容。
| 文档接口 | 当前近似入口或调用 | 主要缺口 |
|---|---|---|
| CA01 `warmup` | `GET /device/v2/bootstrap`、`GET /device/v2/config` | 专门 warmup、绑定代数和 provider_profile 准备状态 |
| CA02 `prepare` | `POST /device/v3/agent-sessions` | 摘机唯一键、签名清单、记忆进度校验、取消先到处理 |
| CA03 `events` | `POST /device/v3/agent-sessions/{id}/events` 及 `/{id}/turns:batch` | 统一事件批次、逐条 ACK、连续序号、字幕修订/缺口/播放事件 |
| CA04 `heartbeat` | EchoEar 发送 `session_updated` 到 v3 events;后端另有 v2 conversations heartbeat | 清单后立即心跳、manifest/media/privacy 绑定、绝对租约、本地到期关闭 |
| CA05 `end` | EchoEar 发送 `session_ended/session_failed`;后端另有 `POST /device/v2/conversations/{id}/stop` | 无 SID 取消、原摘机标识、local_audio_stopped、独立 sdk_stop_status/remote_state |
| CA06 `status` | `GET /device/v2/conversations/{id}` | boot/hook 查询、清单状态、字幕状态、隐私版本及命令 |
| CA07 `command-acks` | `POST /device/v2/commands/{id}/ack` | 当前通话代数、MANIFEST_DISCARDED/LOCAL_AUDIO_OFF/SDK_STOPPED 与提示回执 |
当前已注册入口见 [router.go](E:/Codex/story/platform/internal/httpapi/router.go:308)。EchoEar 真实调用需以 [platform_client.c](E:/Codex/story/platform/firmware/echoear-platform-agent/main/platform_client.c:611) 为准,不能把后端存在的 v2 能力自动算成当前固件已使用。
## 建议的对齐顺序
1. **先冻结设备契约与真实供应商组合**:明确按文档 RTC 路线升级;核验动态上下文、原生检索、关闭路径、字幕归属和用户范围授权。文档附件 H 本身也把这些列为待实测能力,不能因代码中存在 MemoryConfig 字段就判定通过。
2. **补齐启动与撤销协议**:设备 HMAC 契约、warmup/prepare、摘机幂等与取消、Session Manifest、双代数、媒体尝试、首次心跳、本地租约、STOP_CALL 和停止证明。
3. **统一记忆主库和 Viking 投影**:以 SaaS 主记录为维护入口,补逐条映射、版本、关键积压、可见性、删除防复活及旧会话撤销;解决当前独立 AddSession 抽取与人工维护脱节。
4. **完善记录和管理交互**:字幕修订与持久重试、收齐状态、变更确认及阶段进度、固定提示能力、OPEN_ITEM 与开场候选失效。
5. **再形成正式设备接口交付件**:同步 OpenAPI、设备请求/响应、签名测试向量、重试/错误规则、事件/命令回执和真实板卡联调结果。
优先回归文档 T04~T07、T12~T14、T20~T31、T39~T48;供应商验证重点为 H02/H03/H05/H06/H12。此处是建议验收范围,不表示本次已执行或通过。
## 核对方法和边界
- 完整读取了 Word 正文、表格及附件 A~L,并查看了全部 10 张内嵌流程图。
- 使用实际路由、结构体、调用顺序和持久化代码验证主要差异,并检索相关后端、迁移、固件及前端是否存在新版核心协议字段。
- 现有运行记录作为历史证据;其中明确保留了真机重启后实际回忆等待验证项。本次没有把旧记录扩大为新版本验收结论。
- 当前环境的文档渲染器缺少 `soffice.exe`,未取得可核验页码,因此文档依据采用章节/附件定位;未改写或重新导出用户的 Word 文件。
- 本次只新增这份分析,不修改业务代码;未运行生产调用、设备刷写或发布操作。已有工作区修改保留。
+2 -2
View File
@@ -23,10 +23,10 @@
| 记忆修订与停用 | 当前记忆、正文或状态、原因、强ETag | 正文变化创建新版本并立即使旧索引失效;停用后不再召回;重新启用必须重新校验授权并重新索引;`PENDING_DELETE/SUPERSEDED/DELETED`均禁止修改 | 新记忆版本或状态 |
| 记忆永久删除 | 当前记忆、删除原因、强ETag | 事务内先进入`PENDING_DELETE`并清空活动向量,使新通话立即停止召回;该状态不可通过修改接口恢复;Outbox后台把索引标记删除、清除所有记忆版本正文并记录完成审计 | `DELETED`记忆和索引 |
| 新通话记忆检索 | 服务端已确定的客户组织、长者和通话目标 | SQL同时限制租户、长者、当前授权、活动记忆、当前版本、普通敏感级别、有效期和活动索引;最多5条 | 冻结到`context_snapshot.memory_versions`的版本引用和最小背景摘要 |
| 通话中记忆工具 | 供应商持有的单通话短期签名令牌、自然语言查询 | 令牌只绑定服务端既有通话和任务;租户、家庭和长者边界从数据库反查,模型不能传入或扩大;单条240字、总计800字、最多5条;Embedding失败返回空结果并标记降级,不中断通话 | `/internal/v1/tools/memory-search`、`memory_tool_call`和实际使用追踪 |
| 通话中记忆工具 | 供应商持有的单通话短期签名令牌、自然语言查询 | 令牌只绑定服务端既有通话和RTC任务或硬件智能体会话;租户、家庭和长者边界从数据库反查,模型不能传入或扩大;单条240字、总计800字、最多5条;Embedding失败返回空结果并标记降级,不中断通话 | `/internal/v1/tools/memory-search`、`memory_tool_call`和实际使用追踪 |
| 可选供应商原生记忆 | 当前人工确认记忆、有效授权、普通敏感级别、受控HTTP适配器 | 数据库始终是主数据;开启后用Outbox同步,修订先清理旧引用,停用、授权撤回、单条删除和主体删除均可靠删除并对账;关闭功能后通话继续走数据库检索 | `provider`索引的`PENDING/ACTIVE/FAILED/DELETED`状态 |
| 内容质量反馈 | 故事、事实或记忆的人工确认、修改、拒绝、停用及九类标准问题 | 不复制私密正文;按生成模型和提示词版本聚合候选数、审核动作和问题类型;跨租户统一拒绝 | 维护端“内容质量”与`content_review_signal` |
| 跨通话连续上下文 | 最近已完成通话的就绪摘要、当前授权快照、交流偏好 | 只有`story_extraction`和`personalized_memory`在新通话冻结快照中同时有效时,才按同租户同长者读取最近3次摘要;读取结果每条最多240字,供应商上下文按每条140字压缩且总计不超过1000字;撤回任一授权后不再注入 | 冻结到新通话`context_snapshot.sanitized_summary`的短期连续上下文 |
| 跨通话连续上下文 | 最近已完成通话同一最新生成版本的就绪摘要与开放话题、当前授权快照、交流偏好 | 只有`story_extraction`和`personalized_memory`在新通话冻结快照中同时有效时,才按同租户同长者读取最近3次内容;最多选一个最新的非空开放话题作为低优先级建议,摘要保留约170字、已确认记忆每条保留约90字,总计不超过1000字;撤回任一授权后不再注入 | 冻结到新通话`context_snapshot.sanitized_summary`的短期连续上下文 |
| 避免主动提及 | 长者档案`conversation_preferences.avoided_topics` | 最多8项、每项50字;从下一次新通话起进入冻结快照;仅限制智能体主动开启话题,长者主动提及时温和转向,紧急安全响应不受限制 | 新通话的交流边界 |
| 授权撤回 | `personalized_memory`授权 | 在同一事务内停用该长者的活动记忆,清空活动/待处理索引向量并标记失效 | `DISABLED`记忆、`STALE`索引 |
+27
View File
@@ -0,0 +1,27 @@
# 用户端手机适配
适配范围为 `CustomerPortal` 的全部 11 个页面:首页、聊天记录、故事与记忆、长者资料、提醒与待办、家庭管理、陪伴设备、内容分享、数据与授权、服务用量、设置与权限,以及这些页面的资料编辑、邀请、设备配置、审核和授权弹窗。
- 900px 及以下使用抽屉导航,支持搜索、遮罩关闭、Escape、关闭后恢复焦点和窗口尺寸变化。桌面仍使用侧栏。
- 600px 及以下将表单、列表操作和详情改为单列,长名称和资源编号可换行。家庭切换、年度活动日历与权限表在各自区域内横向滚动,手机日历默认定位到最近日期。
- 聊天采用列表与摘要上下排列,选中后定位摘要,可返回聊天列表;手机上的“更多筛选”保留语音方式和起止日期筛选。
- 表单控件使用适合触屏的尺寸,手机输入文字至少为 16px;年度日历日期格为 24px。弹窗使用动态视口高度和安全区域边距,长表单可以滚动到底部。
- 手机覆盖规则集中在 `web/src/customer-responsive.css`,在现有主题之后加载,限定于用户端。
## 回归检查
在 `web` 目录执行 `npm run dev -- --host 127.0.0.1`。在项目根目录使用已安装的 Playwright 运行:
```powershell
$env:PLAYWRIGHT_REQUIRE_FROM = '包含 Playwright 依赖的目录/package.json'
$env:VISUAL_WEB_URL = 'http://127.0.0.1:5173'
node test/e2e/visual-customer-mobile.mjs
```
默认使用本机 Google Chrome;可通过 `CHROME_PATH` 指定浏览器可执行文件。未安装 Playwright 时,可先用 `npm install --prefix .tmp-mobile-qa --no-audit --no-fund playwright` 安装到忽略的临时目录,再把 `PLAYWRIGHT_REQUIRE_FROM` 设置为该目录下 `package.json` 的绝对路径。
脚本覆盖 320、375、390、430、768、900、1024、1440px 的所有页面,以及横屏、登录、空数据、加载失败、短视口表单、导航交互和主要操作弹窗。`MOBILE_WIDTHS` 可指定逗号分隔的宽度;`MOBILE_INTERACTIONS_ONLY=1` 只执行对应宽度的交互检查。截图和检查结果保存在 `.design-evidence/customer-mobile/`。
测试使用合成家庭数据拦截全部业务 API,非 GET 请求会直接失败,不依赖后端或修改业务数据。该检查验证布局和前端交互,不代替真实身份认证、后端业务验收或 iPhone Safari / Android 真机软键盘测试。
构建检查:在 `web` 目录执行 `npm run build`。
+11 -1
View File
@@ -1,7 +1,7 @@
# 设备 API 兼容性约定
> 当前设备接口主版本:`/device/v2`
> 当前接口契约:OpenAPI 0.73.0
> 当前接口契约:OpenAPI 0.78.0
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
## 1. 兼容目标
@@ -18,6 +18,10 @@
## 3. 必须升级到`/device/v3`的变更
当前`/device/v3/hardware-agent-credentials:provision`和`/device/v3/agent-sessions`承载火山硬件对话智能体控制面:前者由平台代设备动态注册且不下发Product Secret,后者在设备直连供应商音频前预取冻结的人设、记忆和授权配置,并回报会话事件与终稿。现有RTC/VoiceChat设备继续使用完整的`/device/v2`接口,平台不得将v3字段或状态语义反向强加给v2固件。
v3硬件会话必须在创建时冻结SDK版本、音频格式、可靠字幕、动态配置、凭证轮换和双路PCM录制能力,并以`session_updated`作为平台存活检查。`session_started`只有在设备证明本次`session.updated`已应用且Bot、传输、配置摘要匹配后才会激活。事件响应中的`action=stop_session`是强制控制动作:设备先断开供应商,再以新的单调序号回报结束或失败;`continue`才允许保持连接。低负载传输不具备供应商服务端状态回调,因此不得把设备事件和终稿上报能力声明为可选。硬件v3仍处于目标板卡接入期,本次新增必填能力字段不改变已经量产的v2契约。
以下任一变化都属于破坏性变更:删除或重命名字段、改变字段类型或业务含义、给既有请求增加必填字段、缩小已发布枚举导致旧值失效、改变签名算法或规范化规则、让旧固件收到无法安全忽略的新控制动作、改变既有状态机或重试/幂等语义。此类变更必须新增`/device/v3`,并保留v2迁移期和独立契约。
CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查。若确实需要破坏性变更,应先建立新主版本路径和迁移方案,不得通过修改或删除旧版契约绕过门禁。
@@ -42,6 +46,8 @@ CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查
| OpenAPI版本 | 设备兼容变更 | 兼容结论 |
|---|---|---|
| 0.78.0 | 新增独立 `/api/v1/device/*` 首次接入协议;physical_confirm设备使用DEVREQ1签名和v3首通许可;旧设备的HMAC、v2路径和一键认领规则保留 | 新库存显式选择新协议;旧设备兼容 |
| 0.77.0 | 新增v3平台代注册端点;Windows硬件智能体客户端不再接收Product Secret,既有v2接口不变 | v2完全兼容;硬件v3客户端升级 |
| 0.68.0 | 生产基础设施状态增加实际应用运行环境与身份服务模式证明;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
| 0.67.0 | 发布候选快照增加生产基础设施强制门禁及主机部署模式、异地备份、外部告警、模拟服务状态;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
| 0.66.0 | 发布候选技术验收快照增加主机健康当前性门禁;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
@@ -74,3 +80,7 @@ CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查
| 0.34.0 | 增加短期RTC凭证续签动作 | 由0.41.0补充能力协商,消除旧终端收到未知动作的风险 |
每次OpenAPI版本更新都必须同步本表;设备端破坏性变更还必须给出v2保留期限、v3升级方式、灰度范围和回滚条件。
## 首次接入协议隔离
`ONBOARDING-1.2-SAAS` 仅适用于生产登记为 `physical_confirm` 的新设备,不依靠固件版本猜测协议。工装写入该契约标识、物理编号、raw32字节认证密钥和版本。新设备调用 `/device/v3/agent-sessions`;同一路径新旧认证由已登记身份区分,认证失败不降级。旧 `/device/v2/conversations` 创建不向新设备开放。新设备恢复出厂前须授权解绑,重置响应不含旧claim码。
+847
View File
@@ -0,0 +1,847 @@
# 设备相关接口完整说明
> 版本:2026-09-05
> 契约来源:`internal/httpapi/router.go`、实际处理器、`openapi/openapi.yaml`
> 生产 Base URL:`https://121.41.46.238`
> 适用对象:EchoEar/ESP32-S3 固件、Windows 模拟设备、用户 App/Web、平台运维端。
## 1. 文档范围
本文覆盖当前代码中全部直接或间接管理设备的接口:
| 接口面 | 方法数 | 鉴权 | 用途 |
|---|---:|---|---|
| 设备直连接口 `/device/v2`、`/device/v3` | 20 | 设备 HMAC | 启动、配置、命令、遥测、固件下载、会话、字幕、媒体、火山凭据 |
| 用户设备管理 `/api/v1/devices*` | 10 | OIDC Bearer | 绑定、设备列表、在线状态、影子、命令 |
| 运维设备管理 `/api/v1/ops/devices*` | 9 | OIDC Bearer + `X-Tenant-Id` | 登记、密钥轮换、绑定码、激活状态机 |
| 家庭端与运维端固件接口 | 31 | OIDC Bearer | 签名密钥、版本、上传、灰度、暂停、回滚、下载 |
| 设备诊断处置 | 2 | OIDC Bearer + `X-Tenant-Id` | 强制刷新配置、吊销设备 |
火山硬件智能体 WebSocket 不是平台 HTTP API,但设备聊天必须使用,故在第 10 节单独说明。
## 2. 通用约定
### 2.1 平台响应包络
成功:
```json
{
"request_id": "req-01J...",
"data": {}
}
```
失败:
```json
{
"request_id": "req-01J...",
"error": {
"code": "VALIDATION_FAILED",
"message": "请求参数不正确",
"retryable": false,
"field_errors": [{ "field": "hardware_id", "message": "不能为空" }],
"details": {}
}
}
```
### 2.2 ID、时间和分页
- `{id}`、`device_id`、`conversation_id`、`household_id`、`subject_id` 均为 UUID。
- 时间均为 RFC3339 UTC,例如 `2026-09-05T06:30:00Z`。
- `Idempotency-Key` 长度为 16—128 字符;业务重试保留原正文和原幂等键。
- `If-Match` 使用接口返回的强 ETag,例如 `"7"`。
- 密钥、Token、绑定码和下载地址不得写入普通日志。
### 2.3 设备 HMAC 请求头
所有 `/device/v2/*` 和 `/device/v3/*` 请求必须携带:
| Header | 输入说明 |
|---|---|
| `X-Device-Id` | 平台设备公开 UUID |
| `X-Device-Timestamp` | RFC3339 时间,允许时钟偏差默认 ±5 分钟 |
| `X-Device-Nonce` | 16—128 字符,一次请求只能使用一次 |
| `X-Device-Content-SHA256` | 原始请求正文 SHA-256 小写十六进制;空正文也计算 |
| `X-Device-Signature` | HMAC-SHA256 的 Base64URL 无填充编码 |
签名原文:
```text
HTTP_METHOD大写\n
完整REQUEST_URI(含查询字符串)\n
X-Device-Timestamp\n
X-Device-Nonce\n
X-Device-Content-SHA256
```
## 3. 设备启动、配置、命令和遥测
### 3.1 GET `/device/v2/bootstrap`
介绍:设备上电后报告固件和硬件信息,取得平台权威设备状态、绑定信息、配置版本和服务器时间。
输入:
| 位置 | 字段 | 必填 | 说明 |
|---|---|---:|---|
| Query | `firmware_version` | 是 | 当前固件版本,最长 80 |
| Query | `device_time` | 是 | RFC3339 设备时间 |
| Query | `board_rev` | 否 | 硬件版本 |
| Query | `rtc_sdk_version` | 否 | SDK 版本 |
| Query | `codec` | 否 | 编码,例如 `pcm_s16le` |
| Query | `sample_rate` | 否 | 采样率,例如 `16000` |
| Query | `network` | 否 | 网络类型,例如 `wifi` |
输出:`server_time`、`clock_skew_ms`、设备状态、最低版本、兼容性、`config_revision` 和绑定对象。
示例:
```http
GET /device/v2/bootstrap?firmware_version=1.0.0&board_rev=echoear-v1&codec=pcm_s16le&sample_rate=16000&device_time=2026-09-05T06%3A30%3A00Z&network=wifi
```
```json
{
"request_id": "req-1",
"data": {
"server_time": "2026-09-05T06:30:01Z",
"clock_skew_ms": 1000,
"device": {
"device_id": "10000000-0000-0000-0000-000000000512",
"status": "registered",
"model": "echoear",
"firmware_compatible": true,
"config_revision": 7,
"binding": { "bound": true, "household_id": "11111111-1111-1111-1111-111111111111", "subject_id": "22222222-2222-2222-2222-222222222222", "subject_name": "张大妈" }
}
}
}
```
### 3.2 GET `/device/v2/config`
介绍:读取云端期望配置和当前绑定。设备保存响应 `ETag`,后续用 `If-None-Match` 增量检查。
输入:无正文;可选 Header `If-None-Match: "7"`。
输出:HTTP `200` 返回 `device_id`、`revision`、`desired_config`、`reported_revision`、`binding`;配置未变化返回 `304` 且无正文。
```json
{
"request_id": "req-2",
"data": {
"device_id": "10000000-0000-0000-0000-000000000512",
"revision": 8,
"desired_config": { "voice_runtime": "volc_hardware_agent", "volume": { "speaker": 70 } },
"reported_revision": 7,
"binding": { "bound": true, "subject_id": "22222222-2222-2222-2222-222222222222" }
}
}
```
### 3.3 GET `/device/v2/commands`
介绍:拉取未过期的待执行命令,并增加投递次数。
输入:Query `limit`,范围 1—20,默认 10。
输出:`items[]`;每项包含 `id`、`type`、`payload`、`status`、`expires_at`、`delivery_attempts`、`created_at`。
```json
{
"request_id": "req-3",
"data": {
"items": [{
"id": "33333333-3333-3333-3333-333333333333",
"type": "apply_config",
"payload": { "revision": 8 },
"status": "DELIVERED",
"expires_at": "2026-09-05T07:30:00Z",
"delivery_attempts": 1,
"created_at": "2026-09-05T06:30:00Z"
}]
}
}
```
### 3.4 POST `/device/v2/commands/{id}/ack`
介绍:设备回报命令已接收、成功或失败。
输入:`status` 为 `ACKNOWLEDGED`、`SUCCEEDED`、`FAILED`;`result_summary` 只能包含有限诊断字段,不得含 Secret/Token。
```json
{
"status": "SUCCEEDED",
"result_summary": { "applied_revision": 8 }
}
```
输出:更新后的命令投递对象。
```json
{
"request_id": "req-4",
"data": { "id": "33333333-3333-3333-3333-333333333333", "type": "apply_config", "status": "SUCCEEDED", "result_summary": { "applied_revision": 8 } }
}
```
### 3.5 POST `/device/v2/telemetry:batch`
介绍:批量上报状态、脱敏错误或命令结果;按 `event_key` 和 `sequence_no` 去重,并返回平台动作。
输入:`events` 为 1—500 项;`event_type` 为 `status`、`error` 或 `command_result`,且只携带对应同名对象。
```json
{
"events": [{
"event_key": "boot.000008",
"sequence_no": 8,
"occurred_at": "2026-09-05T06:31:00Z",
"event_type": "status",
"status": {
"firmware_version": "1.0.0",
"reported_config_revision": 8,
"network": { "type": "wifi", "rssi": -51 },
"rtc": { "joined": false, "uplink": false, "downlink": false },
"recording_queue": { "pending_segments": 0, "failed_segments": 0 }
}
}]
}
```
输出:接收/重复数、最新期望配置版本和动作列表。
```json
{
"request_id": "req-5",
"data": { "accepted_count": 1, "duplicate_count": 0, "desired_config_revision": 8, "actions": [{ "type": "pull_commands" }] }
}
```
## 4. 火山硬件智能体平台控制面
### 4.1 POST `/device/v3/hardware-agent-credentials:provision`
介绍:平台使用 Product Secret 代设备调用火山动态注册,只向当前设备返回它自己的一机一密。允许状态:`BOUND`、`PROVISIONING`、`ACTIVE`。
输入:Header `Idempotency-Key`;正文只包含稳定 `hardware_id`。
```json
{ "hardware_id": "echoear-b43a451634a8" }
```
输出:`instance_id`、`product_key`、`device_name`、`hardware_id`、一次性敏感的 `device_secret`、版本和来源。响应必须 `Cache-Control: no-store`。
```json
{
"request_id": "req-6",
"data": {
"instance_id": "6a92945cc0c4689234aef656",
"product_key": "6a9568047dd4b23eacab9b1a",
"device_name": "echoear-100000000512",
"hardware_id": "echoear-b43a451634a8",
"device_secret": "<仅保存到设备安全区>",
"credential_ref": "cred-01J...",
"credential_version": 1,
"credential_source": "platform_dynamic_registration",
"issued_at": "2026-09-05T06:32:00Z"
}
}
```
### 4.2 POST `/device/v3/agent-sessions`
介绍:每次聊天前创建一次平台会话,冻结本次 Bot、授权、人设、记忆、隐私策略和会话证明。仅 `ACTIVE` 设备可调用。
输入:
```json
{
"device_time": "2026-09-05T06:35:00Z",
"monotonic_ms": 123456,
"capabilities": {
"hardware_agent": true,
"sdk_version": "1.1.0-echoear",
"transports": ["low_load"],
"input_audio_format": "pcm_s16le_16000_mono",
"output_audio_format": "pcm_s16le_16000_mono",
"server_event_callback": false,
"transcript_upload": true,
"dynamic_session_config": true,
"credential_rotation": true,
"near_pcm_recording": false,
"far_pcm_recording": false
},
"network": { "type": "wifi", "rssi": -52 },
"requested_goal": "open_conversation",
"config_revision": 8
}
```
输出:
```json
{
"request_id": "req-7",
"data": {
"conversation_id": "44444444-4444-4444-4444-444444444444",
"status": "SESSION_READY",
"runtime_type": "volc_hardware_agent",
"runtime": {
"provider": "volcengine",
"bot_id": "botM4gWQ9A9y",
"transport": "low_load",
"provider_session_ref": "provider-ref",
"session_nonce": "<仅内存保存>",
"config_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"wait_for_session_update": true
},
"session_config": {
"dialog_id": "55555555-5555-5555-5555-555555555555",
"system_prompt": "<本次人设>",
"persona_tone": "亲切、自然",
"authorized_context": "<本次授权背景与记忆>",
"prompt_bundle_version": "minimal-v2"
},
"privacy": { "retention_mode": "transcript_only", "record_near": false, "record_far": false, "upload_allowed": false },
"session": { "engine_mode": "s2s_hybrid", "heartbeat_interval_sec": 15, "allocation_expires_at": "2026-09-05T06:40:00Z" }
}
}
```
### 4.3 POST `/device/v3/agent-sessions/{id}/events`
介绍:设备回流 `session_started`、`session_updated`、`session_ended`、`session_failed`。`sequence_no` 严格递增。
开始事件输入:
```json
{
"event_id": "evt-start-1",
"sequence_no": 1,
"event_type": "session_started",
"occurred_at": "2026-09-05T06:35:03Z",
"provider_session_ref": "provider-ref",
"session_nonce": "<创建会话返回值>",
"provider_connection_ref": "event_LUVjX_wvR",
"bot_id": "botM4gWQ9A9y",
"transport": "low_load",
"config_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"dynamic_config_applied": true
}
```
输出:`status`、平台动作、是否重复和接收时间。
```json
{
"request_id": "req-8",
"data": { "conversation_id": "44444444-4444-4444-4444-444444444444", "status": "ACTIVE", "action": "continue", "duplicate": false, "accepted_at": "2026-09-05T06:35:03Z" }
}
```
`action=stop_session` 时必须立即停麦、断开火山,再上报 `session_ended` 或 `session_failed`。
### 4.4 POST `/device/v3/agent-sessions/{id}/turns:batch`
介绍:可靠上传最终字幕;禁止上传增量字幕。单批 1—500 条。
输入:
```json
{
"turns": [{
"turn_key": "user-final-1",
"sequence_no": 1,
"speaker": "subject",
"start_at": "2026-09-05T06:35:10Z",
"end_at": "2026-09-05T06:35:12Z",
"final_text": "你好。",
"interrupted": false
}]
}
```
输出:
```json
{ "request_id": "req-9", "data": { "conversation_id": "44444444-4444-4444-4444-444444444444", "accepted_count": 1, "duplicate_count": 0 } }
```
## 5. 旧 RTC 会话兼容接口
新 EchoEar 使用硬件智能体 v3,不应调用本节的创建/激活接口。本节仅供存量 RTC 设备。
### 5.1 POST `/device/v2/conversations`
介绍:分配 RTC 房间和短期 Token,不启动智能体。
输入:
```json
{
"device_time": "2026-09-05T06:40:00Z",
"monotonic_ms": 130000,
"capabilities": { "rtc": true, "audio_tracks": ["near", "far"], "upload_resume": true, "rtc_credential_renewal": true },
"network": { "type": "wifi", "rssi": -50 },
"requested_goal": "open_conversation"
}
```
输出:`conversation_id`、`ROOM_ISSUED`、RTC 房间/用户/Token、隐私策略和心跳配置。
```json
{
"request_id": "req-10",
"data": {
"conversation_id": "66666666-6666-6666-6666-666666666666",
"status": "ROOM_ISSUED",
"rtc": { "app_id": "app", "room_id": "room", "user_id": "device-user", "agent_user_id": "agent-user", "token": "<仅内存>", "expires_at": "2026-09-05T06:45:00Z" },
"privacy": { "retention_mode": "none", "record_near": false, "record_far": false, "upload_allowed": false },
"session": { "engine_mode": "cascade", "heartbeat_interval_sec": 10, "allocation_expires_at": "2026-09-05T06:45:00Z" }
}
}
```
### 5.2 POST `/device/v2/conversations/{id}/activate`
介绍:设备确认已成功进入指定 RTC 房间后,触发平台启动旧语音任务。
输入:
```json
{ "joined_at": "2026-09-05T06:40:02Z", "room_id": "room", "rtc_user_id": "device-user", "rtc_join_result": "success", "config_revision": 8, "connection_reference": "rtc-conn-1" }
```
输出:
```json
{ "request_id": "req-11", "data": { "conversation_id": "66666666-6666-6666-6666-666666666666", "status": "STARTING_AGENT", "accepted_at": "2026-09-05T06:40:02Z" } }
```
### 5.3 GET `/device/v2/conversations/{id}`
介绍:读取平台权威会话状态和设备下一步动作。
输入:Path `id`。
输出示例:
```json
{ "request_id": "req-12", "data": { "conversation_id": "66666666-6666-6666-6666-666666666666", "status": "ACTIVE", "action": "continue" } }
```
失败时可能返回 `status=FAILED`、`action=leave_room`、稳定脱敏的 `failure_code`。
### 5.4 POST `/device/v2/conversations/{id}/heartbeat`
介绍:刷新会话租约、设备在线状态和配置状态;返回停止、续签或配置动作。
输入:
```json
{
"sequence_no": 2,
"device_time": "2026-09-05T06:40:12Z",
"monotonic_ms": 142000,
"conversation_status": "ACTIVE",
"rtc": { "joined": true, "uplink": true, "downlink": true },
"network": { "type": "wifi", "rssi": -49 },
"firmware_version": "1.0.0",
"reported_config_revision": 8,
"recording_queue": { "pending_segments": 0, "failed_segments": 0 },
"command_results": []
}
```
输出:
```json
{ "request_id": "req-13", "data": { "lease_expires_at": "2026-09-05T06:40:42Z", "actions": [{ "type": "continue" }], "desired_config_revision": 8, "commands": [] } }
```
### 5.5 POST `/device/v2/conversations/{id}/stop`
介绍:设备挂断并可靠停止旧供应商任务;支持幂等重放。
输入:
```json
{ "reason": "user_hangup", "device_time": "2026-09-05T06:42:00Z", "last_heartbeat_sequence": 5, "rtc_left": true, "recording": { "near_segments": 0, "far_segments": 0 } }
```
输出:
```json
{ "request_id": "req-14", "data": { "conversation_id": "66666666-6666-6666-6666-666666666666", "status": "STOPPING", "accepted_at": "2026-09-05T06:42:00Z" } }
```
### 5.6 POST `/device/v2/conversations/{id}/turns:batch`
介绍:旧 RTC 回调缺少终稿时,由设备补传最终字幕。输入输出与 4.4 完全相同。
```json
{ "turns": [{ "turn_key": "agent-final-2", "sequence_no": 2, "speaker": "agent", "start_at": "2026-09-05T06:41:00Z", "end_at": "2026-09-05T06:41:02Z", "final_text": "您好。", "interrupted": false }] }
```
## 6. 设备录音上传接口
仅当本次 `privacy.upload_allowed=true` 且对应轨道获授权时使用。
### 6.1 POST `/device/v2/conversations/{id}/upload-batches`
介绍:声明待上传录音分片,平台返回每个对象的短期 PUT 地址;业务 API 不接收音频字节。
输入:
```json
{
"segments": [{
"track": "near",
"sequence_no": 0,
"codec": "wav_pcm_s16le",
"content_type": "audio/wav",
"duration_ms": 10000,
"expected_size": 320044,
"expected_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}]
}
```
输出:批次状态和分片;创建响应中的分片包含 `upload_url`、`upload_until`。
```json
{
"request_id": "req-15",
"data": {
"id": "77777777-7777-7777-7777-777777777777",
"conversation_id": "66666666-6666-6666-6666-666666666666",
"status": "UPLOADING",
"segment_count": 1,
"expected_total_size": 320044,
"verified_count": 0,
"rejected_count": 0,
"verification_attempts": 0,
"expires_at": "2026-09-05T07:02:00Z",
"segments": [{ "id": "88888888-8888-8888-8888-888888888888", "track": "near", "sequence_no": 0, "status": "AUTHORIZED", "upload_url": "https://object.example/put?...", "upload_until": "2026-09-05T07:02:00Z" }]
}
}
```
### 6.2 POST `/device/v2/upload-batches/{id}/complete`
介绍:声明分片已上传,平台开始异步对象大小、摘要和容器校验。
输入:无正文;Path `id` 为上传批次 UUID。
输出:HTTP `202`,返回批次,`status` 通常为 `VERIFYING`。
```json
{ "request_id": "req-16", "data": { "id": "77777777-7777-7777-7777-777777777777", "status": "VERIFYING", "segment_count": 1, "verified_count": 0, "rejected_count": 0, "segments": [] } }
```
### 6.3 GET `/device/v2/upload-batches/{id}`
介绍:轮询后台校验结果。只有 `VERIFIED` 分片返回 `local_delete_after`。
输入:Path `id`。
输出示例:
```json
{ "request_id": "req-17", "data": { "id": "77777777-7777-7777-7777-777777777777", "status": "COMPLETED", "segment_count": 1, "verified_count": 1, "rejected_count": 0, "segments": [{ "id": "88888888-8888-8888-8888-888888888888", "status": "VERIFIED", "local_delete_after": "2026-09-05T07:10:00Z" }] } }
```
### 6.4 POST `/device/v2/media-segments/{id}/local-delete-ack`
介绍:到达平台允许删除时间后,设备删除本地密文并回报;不会删除云端对象。
输入:
```json
{ "status": "DELETED" }
```
失败示例:`{ "status": "FAILED", "error_code": "FLASH_DELETE_FAILED" }`。
输出:更新后的分片对象,包含 `local_delete_status` 和 `local_delete_reported_at`。
## 7. 设备固件下载接口
### GET `/device/v2/firmware-releases/{id}/download`
介绍:仅向当前灰度批次中的目标设备签发 5 分钟私有下载地址。
输入:Path `id` 为固件版本 UUID,无正文。
输出:
```json
{ "request_id": "req-18", "data": { "download_url": "https://object.example/firmware.bin?...", "download_until": "2026-09-05T07:05:00Z" } }
```
非目标设备或暂停/退役版本返回 `403 FIRMWARE_DOWNLOAD_DENIED`。
## 8. 用户 App/Web 设备管理接口
统一输入 Header:`Authorization: Bearer <OIDC access token>`。以下表中“示例输入/输出”均省略通用包络字段。
| 方法与路径 | 接口介绍 | 输入 | 输出 | 示例 |
|---|---|---|---|---|
| `POST /api/v1/device-claims` | 消费一次性绑定码 | Body:`claim_code`、`household_id`、`subject_id` | `binding_id`、`device_id`、归属、`bound_at` | 入:`{"claim_code":"CLAIM-...","household_id":"<uuid>","subject_id":"<uuid>"}`;出:`{"binding_id":"<uuid>","device_id":"<uuid>","bound_at":"2026-09-05T07:00:00Z"}` |
| `GET /api/v1/devices` | 列出当前账号可管理设备 | 无 | `items: ManagedDevice[]` | `{"items":[{"id":"<uuid>","model":"echoear","status":"registered","desired_revision":8}]}` |
| `GET /api/v1/devices/{id}` | 查询设备与绑定摘要 | Path:设备 UUID | `ManagedDevice` | `{"id":"<uuid>","binding_id":"<uuid>","subject_name":"张大妈","firmware_version":"1.0.0","version":3}` |
| `GET /api/v1/devices/{id}/binding-impact` | 解绑/转移前检查阻断项 | Query:`action=unbind\|transfer`;转移时 `target_subject_id` | 影响、`allowed`、`blockers`、当前 `binding_id` | `{"action":"transfer","allowed":true,"pending_command_count":0,"active_firmware_task_count":0}` |
| `POST /api/v1/devices/{id}/binding-change` | 执行解绑或转移 | Body:`action`、`expected_binding_id`、`reason`、`acknowledged=true`;转移时 `target_subject_id` | 旧/新绑定和变更时间 | 入:`{"action":"unbind","expected_binding_id":"<uuid>","reason":"用户解除绑定","acknowledged":true}`;出:`{"device_id":"<uuid>","action":"unbind","changed_at":"..."}` |
| `GET /api/v1/devices/{id}/presence` | 查询实时在线状态 | Path:设备 UUID | `online`、`status`、`last_seen_at`、`source` | `{"device_id":"<uuid>","online":true,"status":"ACTIVE","last_seen_at":"...","source":"redis"}` |
| `GET /api/v1/devices/{id}/twin` | 查询设备影子 | Path:设备 UUID | 期望/上报配置、版本、能力、同步状态;Header `ETag` | `{"device_id":"<uuid>","desired_revision":8,"desired_config":{"volume":{"speaker":70}},"sync_state":"applied"}` |
| `PATCH /api/v1/devices/{id}/twin` | 修改非敏感期望配置 | Header:`If-Match`;Body:`desired_config`,顶层仅 `volume/interaction/upload/rtc/policy_refs` | 更新后的 `DeviceTwin` 和新 `ETag` | `{"desired_config":{"volume":{"speaker":60}}}` |
| `GET /api/v1/devices/{id}/commands` | 查询最近 100 条命令 | Path:设备 UUID | `items: DeviceCommand[]` | `{"items":[{"id":"<uuid>","type":"reboot","status":"SUCCEEDED"}]}` |
| `POST /api/v1/devices/{id}/commands` | 下发可靠命令 | Header:`Idempotency-Key`;Body:`type`、`payload`、`expires_in_sec` | `DeviceCommand`,首次 `202`,重放 `200` | 入:`{"type":"reboot","payload":{"reason":"maintenance"},"expires_in_sec":300}`;出:`{"id":"<uuid>","status":"QUEUED","delivery_attempts":0}` |
`ManagedDevice` 主要输出字段:`id`、`binding_id`、`household_id`、`subject_id`、`subject_name`、`model`、`hardware_revision`、`status`、固件/配置版本、`last_reported_at`、`bound_at`、`registered_at`、`version`。
## 9. 平台运维设备管理接口
统一输入 Header:`Authorization: Bearer <operator token>`、`X-Tenant-Id: <tenant UUID>`。只有独立运维账号可调用。
| 方法与路径 | 接口介绍 | 输入 | 输出 | 示例 |
|---|---|---|---|---|
| `GET /api/v1/ops/devices` | 查询租户全部设备 | 无 | `items: OpsDevice[]`,不返回密钥 | `{"items":[{"id":"<uuid>","model":"echoear","activation_state":"ACTIVE","credential_status":"active","voice_runtime":"volc_hardware_agent"}]}` |
| `POST /api/v1/ops/devices` | 登记新设备并生成平台 HMAC 密钥 | Body:`model` 必填;可选硬件/最低版本/归属/运行时/初始激活状态 | `device`、只显示一次的 `credential`;可能同时返回 `claim` | 入:`{"model":"echoear","hardware_revision":"esp32-s3","voice_runtime":"volc_hardware_agent","activation_state":"FACTORY"}`;出:`{"device":{"id":"<uuid>"},"credential":{"device_id":"<uuid>","secret":"<64位hex>","key_version":1}}` |
| `GET /api/v1/ops/device-assignment-options` | 查询可分配家庭和长者 | 无 | `items[]` 含家庭/长者 ID 和名称 | `{"items":[{"household_id":"<uuid>","household_name":"张家","subject_id":"<uuid>","subject_name":"张大妈"}]}` |
| `PATCH /api/v1/ops/devices/{id}` | 更新设备型号、版本策略、状态、归属或运行时 | Header:`If-Match`;Body 至少一个可修改字段 | 更新后的 `OpsDevice` | `{"minimum_firmware_version":"1.1.0","status":"registered"}` |
| `DELETE /api/v1/ops/devices/{id}` | 软删除并吊销设备/凭据/绑定 | Header:`If-Match` | 吊销后的 `OpsDevice` | `{"id":"<uuid>","status":"revoked","activation_state":"REVOKED","credential_status":"revoked"}` |
| `POST /api/v1/ops/devices/{id}/credentials/rotate` | 轮换平台 HMAC 密钥,旧密钥立即失效 | 无正文 | 只显示一次的新 `secret`、`key_version`、`issued_at` | `{"device_id":"<uuid>","secret":"<64位hex>","key_version":2,"issued_at":"..."}` |
| `POST /api/v1/ops/devices/{id}/claims` | 为未绑定设备生成 24 小时绑定码 | 无正文 | 只显示一次的 `claim_code` 和过期时间 | `{"device_id":"<uuid>","claim_code":"CLAIM-...","issued_at":"...","expires_at":"..."}` |
| `POST /api/v1/ops/devices/{id}/activation-transitions` | 执行受控激活状态迁移 | Body:`target_state`、`reason`、`expected_revision` | 更新后的 `OpsDevice` | `{"target_state":"CLAIMABLE","reason":"工厂质检通过","expected_revision":1}` |
| `GET /api/v1/ops/devices/{id}/activation-transitions` | 查询完整激活审计历史 | Path:设备 UUID | `items: DeviceActivationTransition[]` | `{"items":[{"from_state":"FACTORY","to_state":"CLAIMABLE","reason":"工厂质检通过","activation_revision":2,"created_at":"..."}]}` |
激活状态:`FACTORY → CLAIMABLE → BOUND → PROVISIONING → ACTIVE → LOST/REVOKED`。设备不能自行修改该状态。
## 10. 火山硬件智能体 WebSocket
### 10.1 建连
```text
wss://ai-gateway.vei.volces.com/v1/realtime?bot=botM4gWQ9A9y&wait_for_session_update=true
```
输入 Header:
| Header | 说明 |
|---|---|
| `X-Auth-Type` | 固定 `1` |
| `X-Product-Key` | 平台预配返回的 Product Key |
| `X-Device-Name` | 平台预配返回的 Device Name |
| `X-Random-Num` | 本次随机整数 |
| `X-Timestamp` | Unix 秒 |
| `X-Instance-Id` | 平台预配返回的 Instance ID |
| `X-Signature` | 使用火山 Device Secret 计算的 HMAC-SHA256 Base64 |
| `X-Hardware-Id` | 稳定 Hardware ID |
签名正文顺序固定:
```text
auth_type=1&device_name={name}&random_num={random}&product_key={key}&timestamp={unix}&instance_id={instance}
```
### 10.2 动态配置输入
连接打开后立即发送:
```json
{
"event_id": "windows-session-update-<uuid>",
"type": "session.update",
"session": {
"object": "realtime.session",
"model": "",
"input_audio_format": "pcm16",
"output_audio_format": "pcm16",
"config": {
"ASRConfig": { "TurnDetectionMode": 0 },
"LLMConfig": { "SystemMessages": ["<平台冻结的人设与授权上下文>"] }
},
"agent_config": { "EnableConversationStateCallback": false }
}
}
```
输出:`session.created` 或 `session.updated`;在收到确认前不得开启麦克风。
### 10.3 音频和文本输入
上行音频:PCM16 little-endian、16kHz、单声道,建议每 20ms 320 个采样。
```json
{ "event_id": "audio-<uuid>", "type": "input_audio_buffer.append", "audio": "<PCM Base64>" }
```
文本测试:
```json
{
"event_id": "text-<uuid>",
"type": "conversation.item.create",
"item": { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "你好" }], "interrupt_mode": 1 }
}
```
### 10.4 设备消费的输出事件
| 事件 | 输出内容 | 设备动作 |
|---|---|---|
| `input_audio_buffer.speech_started/stopped` | 服务端 VAD 状态 | 更新交互状态 |
| `conversation.item.input_audio_transcription.completed` | 用户最终字幕 | 上传平台 `turns:batch` |
| `response.audio.delta` | Base64 PCM16、16kHz、单声道 | 连续缓冲并按声卡采样率重采样播放 |
| `response.audio.done` | 本轮音频结束 | 排空播放缓冲 |
| `response.audio_transcript.delta/done` | 智能体增量/最终字幕 | 只把最终字幕上传平台 |
| `response.function_call_arguments.done` | 工具名、参数和 `call_id` | 调用平台受控工具并回传结果 |
| `response.done` | 本轮完成 | 继续监听 |
| `error` | 供应商错误 | 脱敏后上报 `session_failed` |
注意:low-load WebSocket 下行是 16kHz。把它按 24kHz 播放会出现约 1.5 倍速、音调升高和明显电子感。
## 11. 固件管理接口
### 11.1 家庭/租户授权接口
| 方法与路径 | 介绍 | 输入 | 输出 |
|---|---|---|---|
| `GET /api/v1/households/{id}/firmware-signing-keys` | 查询可信 Ed25519 公钥 | Path `household id` | `FirmwareSigningKey[]` |
| `POST /api/v1/households/{id}/firmware-signing-keys` | 登记签名公钥 | `key_id`、`public_key_base64` | `201`,创建结果 |
| `POST /api/v1/households/{id}/firmware-signing-keys/revoke` | 吊销公钥 | `key_id` | `FirmwareSigningKey` |
| `GET /api/v1/households/{id}/firmware-releases` | 查询固件版本 | Path `household id` | `FirmwareRelease[]` |
| `POST /api/v1/households/{id}/firmware-releases` | 创建 `UPLOADING` 版本 | `PrepareFirmwareReleaseRequest` | 版本和短期上传地址 |
| `GET /api/v1/firmware-releases/{id}/impact` | 发布/暂停/退役前影响评估 | Path `release id` | `FirmwareReleaseImpact` |
| `POST /api/v1/firmware-releases/{id}/upload-session` | 重新签发上传地址 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/firmware-releases/{id}/finalize` | 校验大小、SHA-256、Ed25519 后发布 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/firmware-releases/{id}/pause` | 暂停版本和未投递命令 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/firmware-releases/{id}/resume` | 恢复版本分发 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/firmware-releases/{id}/retire` | 退役版本并保留审计 | 无正文 | `FirmwareRelease` |
| `GET /api/v1/households/{id}/firmware-rollouts` | 查询 OTA 批次 | Path `household id` | `FirmwareRollout[]` |
| `POST /api/v1/households/{id}/firmware-rollouts` | 启动灰度升级 | `StartFirmwareRolloutRequest` | `202 FirmwareRollout` |
| `GET /api/v1/firmware-rollouts/{id}` | 查询逐设备升级进度 | Path `rollout id` | `FirmwareRollout` |
| `POST /api/v1/firmware-rollouts/{id}/pause` | 暂停批次 | 无正文 | `FirmwareRollout` |
| `POST /api/v1/firmware-rollouts/{id}/rollback` | 创建回滚批次 | `rollback_release_id` | 新的 `FirmwareRollout` |
### 11.2 运维固件接口
以下接口全部额外要求 `X-Tenant-Id`:
| 方法与路径 | 介绍 | 输入 | 输出 |
|---|---|---|---|
| `GET /api/v1/ops/firmware-signing-keys` | 查询租户可信公钥 | 无 | `FirmwareSigningKey[]` |
| `POST /api/v1/ops/firmware-signing-keys` | 登记可信公钥 | `key_id`、`public_key_base64` | `201` |
| `POST /api/v1/ops/firmware-signing-keys/revoke` | 吊销公钥 | `key_id` | `FirmwareSigningKey` |
| `GET /api/v1/ops/firmware-releases` | 按 `model/status` 查询版本 | Query `model`、`status` 可选 | `FirmwareRelease[]` |
| `POST /api/v1/ops/firmware-releases` | 创建租户级版本 | `PrepareOpsFirmwareReleaseRequest` | `201 FirmwareRelease` |
| `POST /api/v1/ops/firmware-releases/{id}/upload-session` | 续签上传地址 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/ops/firmware-releases/{id}/finalize` | 正式发布 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/ops/firmware-releases/{id}/pause` | 暂停 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/ops/firmware-releases/{id}/resume` | 恢复 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/ops/firmware-releases/{id}/retire` | 退役 | 无正文 | `FirmwareRelease` |
| `GET /api/v1/ops/firmware-releases/{id}/download` | 运维下载制品 | Path `release id` | 下载地址、摘要、签名元数据 |
| `GET /api/v1/ops/firmware-rollouts` | 查询 OTA 批次 | 无 | `FirmwareRollout[]` |
| `POST /api/v1/ops/firmware-rollouts` | 启动 OTA | `StartFirmwareRolloutRequest` | `202 FirmwareRollout` |
| `GET /api/v1/ops/firmware-rollouts/{id}` | 查询逐设备进度 | Path `rollout id` | `FirmwareRollout` |
| `POST /api/v1/ops/firmware-rollouts/{id}/pause` | 暂停 OTA | 无正文 | `FirmwareRollout` |
固件请求示例:
```json
{ "key_id": "release-key-2026-01", "public_key_base64": "<32字节Ed25519公钥的Base64>" }
```
```json
{
"model": "echoear",
"version": "1.1.0",
"expected_size": 2097152,
"expected_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"signing_key_id": "release-key-2026-01",
"signature": "<firmware-manifest-v1签名Base64>"
}
```
```json
{ "release_id": "<uuid>", "name": "第一批灰度", "target_percentage": 10, "device_ids": ["<device-uuid>"] }
```
典型版本输出:
```json
{ "request_id": "req-fw", "data": { "id": "<uuid>", "model": "echoear", "version": "1.1.0", "status": "READY", "sha256": "bbbb...", "created_at": "2026-09-05T07:00:00Z" } }
```
典型批次输出:
```json
{ "request_id": "req-rollout", "data": { "id": "<uuid>", "release_id": "<uuid>", "name": "第一批灰度", "status": "RUNNING", "target_percentage": 10, "targeted": 1, "succeeded": 0, "failed": 0 } }
```
## 12. 运维诊断设备动作
| 方法与路径 | 介绍 | 输入 | 输出 | 示例 |
|---|---|---|---|---|
| `POST /api/v1/ops/diagnostics/devices/{id}/config-refresh` | 强制设备重新拉取配置 | `X-Tenant-Id`、`Idempotency-Key`、设备 UUID;Body:`reason` | `action=refresh_config`、命令 ID/状态、`queued` | 入:`{"reason":"远端配置与影子不一致"}`;出:`{"action":"refresh_config","device_id":"<uuid>","command_id":"<uuid>","command_status":"QUEUED","queued":true}` |
| `POST /api/v1/ops/diagnostics/devices/{id}/revoke` | 紧急吊销异常设备 | `X-Tenant-Id`、`Idempotency-Key`、设备 UUID;Body:`reason` | `action=revoke_device`、设备 ID、`queued` | 入:`{"reason":"设备密钥疑似泄露"}`;出:`{"action":"revoke_device","device_id":"<uuid>","queued":false}` |
## 13. 主要错误码
| HTTP | 错误码 | 含义与处理 |
|---:|---|---|
| 400 | `VALIDATION_FAILED` | 字段、Header、时间或幂等键不符合契约 |
| 401 | `DEVICE_AUTHENTICATION_FAILED` | 检查平台 Device Secret、时钟、URI、正文摘要和 Base64URL |
| 403 | `DEVICE_ACTIVATION_REQUIRED` | 当前激活状态不允许该操作 |
| 403 | `TRANSCRIPT_STORAGE_NOT_ALLOWED` | 当前授权不允许保存字幕 |
| 403 | `FIRMWARE_DOWNLOAD_DENIED` | 设备不是灰度目标或版本不可下载 |
| 404 | `RESOURCE_NOT_FOUND` | 资源不存在或不属于当前主体/设备 |
| 409 | `IDEMPOTENCY_KEY_REUSED` | 同一幂等键对应不同正文,客户端必须换业务请求 |
| 409 | `DEVICE_ACTIVATION_CONFLICT` | 激活状态或修订号冲突,刷新后再处理 |
| 409 | `HARDWARE_AGENT_HARDWARE_MISMATCH` | Hardware ID 与已注册记录不一致,禁止覆盖 |
| 409 | `HARDWARE_SESSION_PROOF_INVALID` | `session_nonce`/配置摘要/Bot/传输不匹配,丢弃本次会话重新创建 |
| 412 | `VERSION_CONFLICT` | `If-Match` 过期,先重新 GET |
| 426 | `FIRMWARE_UPDATE_REQUIRED` | 当前固件低于最低版本 |
| 429 | `USAGE_QUOTA_EXCEEDED` | 额度或并发限制达到上限 |
| 503 | `HARDWARE_AGENT_PROVISIONING_UNAVAILABLE` | 平台 Product Secret/产品配置不可用 |
| 503 | `HARDWARE_AGENT_PROVISIONING_FAILED` | 火山动态注册失败;按 `request_id` 排障,可退避重试 |
## 14. 设备端推荐调用顺序
```text
上电
→ GET /device/v2/bootstrap
→ GET /device/v2/config
→ GET /device/v2/commands
→ POST /device/v2/telemetry:batch
首次绑定后
→ POST /device/v3/hardware-agent-credentials:provision
→ 安全保存火山 Device Secret
每次聊天
→ POST /device/v3/agent-sessions
→ 连接火山 WebSocket
→ session.update
→ 收到 session.created/session.updated
→ POST /device/v3/agent-sessions/{id}/events (session_started)
→ 16k PCM 双向音频
→ POST /device/v3/agent-sessions/{id}/turns:batch
→ 周期 events (session_updated)
→ events (session_ended/session_failed)
```
## 15. 机器可读契约
- 完整 OpenAPI:[`../openapi/openapi.yaml`](../openapi/openapi.yaml)
- 设备实现与安全说明:[`device-api-reference.md`](device-api-reference.md)
- 设备接入说明:[`device-integration.md`](device-integration.md)
- 固件灰度说明:[`firmware-rollout.md`](firmware-rollout.md)
若本文与 OpenAPI 或运行时代码不一致,以同一发布版本的 OpenAPI 和处理器为准,并应同步修正文档。
+576
View File
@@ -0,0 +1,576 @@
# 设备接口文档
> 适用范围:平台设备管理、EchoEar/ESP32-S3 设备接入、火山引擎硬件对话智能体。
> 契约基线:当前工作区实际路由、处理器与 `openapi/openapi.yaml`,更新日期 2026-09-05。
> 生产 API:`https://121.41.46.238`(正式域名启用后只替换 Base URL,不改变路径)。
## 1. 接入模型
设备同时使用两套彼此独立的身份:
| 身份/密钥 | 用途 | 生成与保存位置 | 是否下发设备 |
|---|---|---|---|
| 平台 `device_id` | 标识平台登记设备 | 平台生成;设备保存公开编号 | 是 |
| 平台 Device Secret | 调用 `/device/*` 时做 HMAC 鉴权 | 平台逐设备生成;平台加密保存,设备安全区保存 | 是,只在登记/轮换时显示一次 |
| 火山 `instance_id` / `product_key` | 标识火山实例和产品 | 平台配置 | 可随预配响应返回,非秘密 |
| 火山 Product Secret | 调用 DynamicRegister 的产品级秘密 | 只保存在平台 KMS/Secret | **否** |
| 火山 `device_name` / `device_secret` | 设备直连火山时使用的一机一密 | 平台代设备动态注册;平台加密保存,设备安全区保存 | 是,只返回给已认证的对应设备 |
| Bot ID | 选择硬件对话智能体 | 平台按设备绑定 | 会话响应返回,非秘密 |
核心原则:设备固件不内置 Product Secret。设备先用平台 Device Secret 认证,由平台代设备调用火山动态注册,再把该设备专属的火山 Device Secret 返回给设备。
### 1.1 火山 License 的准确含义
火山硬件对话智能体的 License 是产品侧可用额度。平台调用 DynamicRegister 时,火山自动消耗当前产品的一份有效额度并返回一机一密;设备不接收一段可安装的 License 明文,也不需要向平台提交 License 安装回执。
因此,当前有效契约中:
- 没有 `/device/v3/hardware-agent-license:ack` 路由;
- 没有设备端 `license` 字段;
- 没有 `AVAILABLE → ALLOCATED → ACTIVE` 的设备 License 安装状态机;
- 平台可以维护额度监控或影子账本,但不能用它替代火山的真实额度,也不能阻断已正确取得一机一密的设备。
火山返回额度耗尽、过期等错误时,平台统一转换为 `HARDWARE_AGENT_PROVISIONING_FAILED`,同时通过内部日志和请求编号保留供应商错误供运维定位。
## 2. 通用响应格式
成功响应:
```json
{
"request_id": "req-...",
"data": {}
}
```
失败响应:
```json
{
"request_id": "req-...",
"error": {
"code": "VALIDATION_FAILED",
"message": "请求参数不正确",
"retryable": false,
"field_errors": [],
"details": {}
}
}
```
排障时应记录 `request_id`、HTTP 状态码和稳定错误码,不记录任何 Secret、Token、完整人设或完整对话正文。
## 3. 设备 HMAC 鉴权
所有 `/device/v2/*` 和 `/device/v3/*` 请求必须携带:
| 请求头 | 说明 |
|---|---|
| `X-Device-Id` | 平台设备公开 UUID |
| `X-Device-Timestamp` | RFC3339 UTC 时间,默认允许前后 5 分钟 |
| `X-Device-Nonce` | 本次请求唯一的 16—128 字符随机值 |
| `X-Device-Content-SHA256` | 原始 HTTP 正文的 SHA-256 小写十六进制;空正文也要计算 |
| `X-Device-Signature` | HMAC-SHA256 结果的无填充 Base64URL |
签名原文必须逐字使用实际请求 URI(含查询字符串):
```text
UPPERCASE_HTTP_METHOD
REQUEST_URI_WITH_EXACT_QUERY_STRING
X-Device-Timestamp原值
X-Device-Nonce原值
X-Device-Content-SHA256原值
```
伪代码:
```text
bodyHash = hexLower(SHA256(rawBody))
canonical = methodUpper + "\n" + requestURI + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash
signature = base64UrlNoPadding(HMAC_SHA256(platformDeviceSecret, canonical))
```
注意事项:
- JSON 签名后不得重新格式化或改变字段顺序;服务端校验的是收到的原始字节。
- 每次网络重试都生成新的 `timestamp`、`nonce` 和签名。
- 需要幂等的业务请求保留原 `Idempotency-Key` 和原正文。
- 时间未同步时先通过安全时间源校时;不能关闭时间窗或重放保护。
## 4. 设备激活状态机
主流程:
```text
FACTORY → CLAIMABLE → BOUND → PROVISIONING → ACTIVE
│
├─→ LOST
└─→ REVOKED
```
| 状态 | 含义 | 允许的设备操作 |
|---|---|---|
| `FACTORY` | 只有出厂身份,等待质检放行 | 不允许设备 API 鉴权 |
| `CLAIMABLE` | 可生成/消费绑定码 | 启动、配置、命令、遥测;不能通话 |
| `BOUND` | 已绑定家庭和使用人 | 可申请火山一机一密;不能创建会话 |
| `PROVISIONING` | 正在注册或恢复火山运行凭据 | 可重复预配;不能创建会话 |
| `ACTIVE` | 平台身份、绑定和运行凭据就绪 | 全部设备接口 |
| `LOST` | 设备挂失 | 设备鉴权和新会话被拒绝 |
| `REVOKED` | 永久吊销 | 终态,不可恢复 |
代码允许的恢复/解绑边包括:`BOUND → CLAIMABLE`、`PROVISIONING → BOUND`、`ACTIVE → CLAIMABLE/PROVISIONING/LOST`、`LOST → CLAIMABLE/ACTIVE`;任意非 `REVOKED` 状态均可受控转为 `REVOKED`。状态变更必须由平台业务或运维接口执行,设备不能自行声明已激活。
每次变更增加 `activation_revision` 并保存审计历史。进入 `ACTIVE` 时,硬件智能体设备必须已经绑定且存在有效火山一机一密。
## 5. 首次激活完整流程
1. 运维调用 `POST /api/v1/ops/devices` 登记设备,取得一次性平台 Device Secret。
2. 工厂烧录/安全注入 `device_id`、平台 Device Secret、API Base URL 和稳定 `hardware_id`;不得烧录 Product Secret。
3. 运维将设备从 `FACTORY` 放行到 `CLAIMABLE`,并生成绑定码。
4. 用户 App/Web 调用 `POST /api/v1/device-claims` 消费绑定码,平台把设备变为 `BOUND`。
5. 设备使用平台 HMAC 调用 `POST /device/v3/hardware-agent-credentials:provision`。
6. 平台把状态推进到 `PROVISIONING`,自动建立产品默认 Bot/传输/最低 SDK 绑定,再使用服务端 Product Secret 调用火山 DynamicRegister。
7. 火山消耗产品 License 额度并返回该 `device_name` 对应的 `device_secret`。
8. 平台加密保存火山 Device Secret,并通过 `no-store` 响应返回给同一设备,然后自动完成到 `ACTIVE` 的迁移。
9. 设备把火山一机一密写入 NVS 加密分区/安全芯片;以后重复预配会返回已保存的同一凭据,不重复注册。
10. 每次用户发起聊天时,设备先创建平台硬件智能体会话,注入本次人设和上下文后,再直连火山。
## 6. 设备端接口总览
### 6.1 启动、配置、命令和固件(v2 公共能力)
| 方法 | 路径 | 状态要求 | 幂等键 | 作用 |
|---|---|---|---|---|
| `GET` | `/device/v2/bootstrap` | `CLAIMABLE/BOUND/PROVISIONING/ACTIVE` | 否 | 上报启动版本并取得绑定和兼容性 |
| `GET` | `/device/v2/config` | 同上 | 否 | 拉取期望配置;支持 `If-None-Match` |
| `GET` | `/device/v2/commands` | 同上 | 否 | 拉取待执行命令 |
| `POST` | `/device/v2/commands/{id}/ack` | 同上 | 否 | 上报命令阶段或终态 |
| `POST` | `/device/v2/telemetry:batch` | 同上 | 是 | 批量上报设备遥测 |
| `GET` | `/device/v2/firmware-releases/{id}/download` | 同上 | 否 | 下载受控固件制品 |
`bootstrap` 示例:
```http
GET /device/v2/bootstrap?firmware_version=1.0.0&board_rev=echoear-v1&rtc_sdk_version=1.0.0&codec=pcm_s16le&sample_rate=16000&device_time=2026-09-03T08%3A00%3A00Z&network=wifi
```
配置拉取建议:首次不带 `If-None-Match`;保存响应 `ETag`,后续携带该值。HTTP `304` 表示继续使用本地有效配置。
### 6.2 旧 RTC 通话(v2,仅兼容旧设备)
| 方法 | 路径 | 说明 |
|---|---|---|
| `POST` | `/device/v2/conversations` | 创建旧 RTC 通话 |
| `POST` | `/device/v2/conversations/{id}/activate` | 入房成功后激活供应商任务 |
| `GET` | `/device/v2/conversations/{id}` | 查询通话权威状态 |
| `POST` | `/device/v2/conversations/{id}/heartbeat` | 通话心跳 |
| `POST` | `/device/v2/conversations/{id}/stop` | 停止通话 |
| `POST` | `/device/v2/conversations/{id}/turns:batch` | 补传最终字幕 |
新 EchoEar 固件的 `voice_runtime=volc_hardware_agent` 时不得调用这组 RTC 创建/激活接口。
### 6.3 录音上传(v2 公共媒体能力)
| 方法 | 路径 | 作用 |
|---|---|---|
| `POST` | `/device/v2/conversations/{id}/upload-batches` | 创建分片上传批次 |
| `GET` | `/device/v2/upload-batches/{id}` | 查询上传校验状态 |
| `POST` | `/device/v2/upload-batches/{id}/complete` | 通知平台分片已上传 |
| `POST` | `/device/v2/media-segments/{id}/local-delete-ack` | 回报本地密文已删除 |
只有 `ACTIVE` 设备且本次隐私策略允许时才能使用;没有录音授权不得采集、缓存或上传 PCM。
## 7. 火山硬件智能体接口(v3)
### 7.1 申请火山一机一密
```http
POST /device/v3/hardware-agent-credentials:provision
Idempotency-Key: 2ee3b675-59c0-4d93-a189-6db7f8db3750
Cache-Control: no-store
Content-Type: application/json
```
状态要求:`BOUND`、`PROVISIONING` 或 `ACTIVE`。
请求:
```json
{
"hardware_id": "echoear-b43a451634a8"
}
```
`hardware_id` 必须来自稳定硬件身份(如 eFuse/Chip ID 的规范化值),设备重启、升级和恢复出厂后保持不变。
成功响应:
```json
{
"request_id": "req-...",
"data": {
"instance_id": "<instance-id>",
"product_key": "<product-key>",
"device_name": "echoear-...",
"hardware_id": "echoear-b43a451634a8",
"device_secret": "<volc-device-secret>",
"credential_ref": "<non-secret-ref>",
"credential_version": 1,
"credential_source": "platform_dynamic_registration",
"issued_at": "2026-09-03T08:00:00Z"
}
}
```
安全要求:
- 响应带 `Cache-Control: no-store` 和 `Pragma: no-cache`。
- `device_secret` 只写入设备安全存储,不进入普通配置文件、串口日志、崩溃转储或平台遥测。
- `credential_source=platform_stored` 表示幂等返回平台已保存的凭据,不代表重新消耗 License。
- `hardware_id` 与已注册记录不一致时返回冲突,不允许覆盖原凭据。
常见错误:
| HTTP | 错误码 | 处理 |
|---|---|---|
| `400` | `VALIDATION_FAILED` | 修正 `hardware_id` 或幂等键 |
| `409` | `HARDWARE_AGENT_BINDING_REQUIRED` | 先在平台绑定 Bot/产品配置 |
| `409` | `HARDWARE_AGENT_PRODUCT_MISMATCH` | 运维核对实例、产品和设备绑定 |
| `409` | `HARDWARE_AGENT_HARDWARE_MISMATCH` | 停止自动重试,核对硬件身份 |
| `409` | `DEVICE_ACTIVATION_REQUIRED` | 先完成用户绑定,进入 `BOUND` |
| `503` | `HARDWARE_AGENT_PROVISIONING_UNAVAILABLE` | 平台未配置动态注册;联系运维 |
| `503` | `HARDWARE_AGENT_PROVISIONING_FAILED` | 使用原幂等键、原正文和新签名退避重试;由 `request_id` 查火山原始错误 |
### 7.2 创建本次硬件智能体会话
```http
POST /device/v3/agent-sessions
Idempotency-Key: d7f08e7a-9e1d-455f-b8ec-a745a34d8790
Content-Type: application/json
```
状态要求:仅 `ACTIVE`。每次聊天创建一个新会话,不复用上一通响应。
请求示例:
```json
{
"device_time": "2026-09-03T08:05:00Z",
"monotonic_ms": 123456,
"capabilities": {
"hardware_agent": true,
"sdk_version": "1.0.0-echoear-platform.1",
"transports": ["low_load"],
"input_audio_format": "pcm_s16le_16000_mono",
"output_audio_format": "pcm_s16le_16000_mono",
"server_event_callback": false,
"transcript_upload": true,
"dynamic_session_config": true,
"credential_rotation": true,
"near_pcm_recording": false,
"far_pcm_recording": false
},
"network": {
"type": "wifi",
"rssi": -52
},
"requested_goal": "open_conversation",
"config_revision": 1
}
```
约束:
- `transports` 只能包含 `low_load`、`high_quality`,至少一个。
- `transcript_upload`、`dynamic_session_config`、`credential_rotation` 必须为 `true`。
- EchoEar 当前使用 `low_load`;`server_event_callback=false` 时设备事件和最终字幕上传是权威回流。
- `config_revision` 必须使用最近一次 `/device/v2/config` 返回的版本。
成功响应核心字段:
```json
{
"request_id": "req-...",
"data": {
"conversation_id": "<uuid>",
"status": "SESSION_READY",
"runtime_type": "volc_hardware_agent",
"runtime": {
"provider": "volcengine",
"bot_id": "<bot-id>",
"transport": "low_load",
"provider_session_ref": "<provider-session-ref>",
"session_nonce": "<memory-only-secret>",
"config_digest": "<sha256-hex>",
"wait_for_session_update": true
},
"session_config": {
"dialog_id": "<uuid>",
"system_prompt": "<本次冻结的人设提示>",
"persona_tone": "<语气>",
"authorized_context": "<已授权背景/近期摘要/长期记忆>",
"prompt_bundle_version": "<version>"
},
"privacy": {
"retention_mode": "transcript_only",
"record_near": false,
"record_far": false,
"upload_allowed": false
},
"session": {
"engine_mode": "volc_hardware_agent",
"heartbeat_interval_sec": 10,
"allocation_expires_at": "2026-09-03T08:10:00Z"
}
}
}
```
设备处理顺序:
1. 只在运行内存保存 `session_nonce`。
2. 使用预配取得的一机一密连接响应指定的 Bot 和传输模式。
3. 以 `dialog_id` 作为本次业务对话标识。
4. 在开启麦克风前通过 SDK 动态配置或 WebSocket `session.update` 注入 `system_prompt`、`persona_tone` 和 `authorized_context`。
5. 收到火山配置已应用确认后,再上报 `session_started`。
`tool_scope_token` 如存在也只能保存在运行内存,并且仅用于响应指定的 `tool_url` 和本次会话。
### 7.3 上报会话事件
```http
POST /device/v3/agent-sessions/{conversation_id}/events
Content-Type: application/json
```
开始事件:
```json
{
"event_id": "evt-start-0001",
"sequence_no": 1,
"event_type": "session_started",
"occurred_at": "2026-09-03T08:05:03Z",
"provider_session_ref": "<response-runtime-provider-session-ref>",
"session_nonce": "<response-runtime-session-nonce>",
"provider_connection_ref": "<actual-volc-connection-ref>",
"bot_id": "<response-runtime-bot-id>",
"transport": "low_load",
"config_digest": "<response-runtime-config-digest>",
"dynamic_config_applied": true
}
```
后续事件类型:
- `session_updated`:按 `heartbeat_interval_sec` 上报存活;`sequence_no` 严格递增。
- `session_ended`:正常结束。
- `session_failed`:异常结束,可附加稳定、脱敏的 `error_code`。
响应:
```json
{
"request_id": "req-...",
"data": {
"conversation_id": "<uuid>",
"status": "ACTIVE",
"action": "continue",
"duplicate": false,
"accepted_at": "2026-09-03T08:05:03Z"
}
}
```
`action=stop_session` 时,设备必须立即关闭麦克风和火山连接,然后补报 `session_ended` 或 `session_failed`。相同 `event_id` 重放时必须保持原正文一致。
### 7.4 批量上传最终话轮
```http
POST /device/v3/agent-sessions/{conversation_id}/turns:batch
Idempotency-Key: 1710b8cc-9bf2-45e8-a16d-f32c3b80cbbe
Content-Type: application/json
```
请求:
```json
{
"turns": [
{
"turn_key": "user-final-0001",
"sequence_no": 1,
"speaker": "subject",
"start_at": "2026-09-03T08:05:10Z",
"end_at": "2026-09-03T08:05:12Z",
"final_text": "今天天气怎么样?",
"interrupted": false
},
{
"turn_key": "agent-final-0002",
"sequence_no": 2,
"speaker": "agent",
"start_at": "2026-09-03T08:05:12Z",
"end_at": "2026-09-03T08:05:15Z",
"final_text": "我来帮您看看。",
"interrupted": false
}
]
}
```
只上传最终字幕;不得上传增量识别结果。单批 1—500 条,`speaker` 只能为 `subject` 或 `agent`。同一 `turn_key`/`sequence_no` 的重试必须保持正文完全一致。
成功响应:
```json
{
"request_id": "req-...",
"data": {
"conversation_id": "<uuid>",
"accepted_count": 2,
"duplicate_count": 0
}
}
```
## 8. App/Web 的设备接口
以下接口使用用户 OIDC Bearer Token,不使用设备 HMAC:
| 方法 | 路径 | 作用 |
|---|---|---|
| `POST` | `/api/v1/device-claims` | 消费绑定码并绑定设备 |
| `GET` | `/api/v1/devices` | 列出当前用户可管理设备 |
| `GET` | `/api/v1/devices/{id}` | 查看设备详情 |
| `GET` | `/api/v1/devices/{id}/binding-impact` | 变更绑定前评估影响 |
| `POST` | `/api/v1/devices/{id}/binding-change` | 解绑或改绑 |
| `GET` | `/api/v1/devices/{id}/presence` | 查看在线状态 |
| `GET` | `/api/v1/devices/{id}/twin` | 查看设备影子 |
| `PATCH` | `/api/v1/devices/{id}/twin` | 修改期望配置;必须携带 `If-Match` |
| `GET` | `/api/v1/devices/{id}/commands` | 查看命令历史 |
| `POST` | `/api/v1/devices/{id}/commands` | 下发设备命令 |
绑定码由平台/运维为 `CLAIMABLE` 设备生成,App 只消费,不自行生成。解绑会影响设备激活状态和火山运行凭据,必须先调用 `binding-impact` 展示影响并要求用户确认。
## 9. 平台运维设备接口
所有接口要求 OIDC Bearer Token 和有效的 `X-Tenant-Id: <tenant-uuid>`,且仅独立运维账号可调用。
| 方法 | 路径 | 作用 |
|---|---|---|
| `GET` | `/api/v1/ops/devices` | 设备列表 |
| `POST` | `/api/v1/ops/devices` | 登记设备并一次性返回平台 Device Secret |
| `GET` | `/api/v1/ops/device-assignment-options` | 可选家庭/长者列表 |
| `PATCH` | `/api/v1/ops/devices/{id}` | 修改设备;必须携带 `If-Match` |
| `DELETE` | `/api/v1/ops/devices/{id}` | 吊销设备;必须携带 `If-Match` |
| `POST` | `/api/v1/ops/devices/{id}/credentials/rotate` | 轮换平台 Device Secret,只返回一次明文 |
| `POST` | `/api/v1/ops/devices/{id}/claims` | 生成一次性绑定码 |
| `POST` | `/api/v1/ops/devices/{id}/activation-transitions` | 人工迁移激活状态 |
| `GET` | `/api/v1/ops/devices/{id}/activation-transitions` | 查询激活迁移历史 |
### 9.1 登记硬件智能体设备
```json
{
"model": "echoear",
"hardware_revision": "esp32-s3",
"minimum_security_version": "1",
"minimum_firmware_version": "1.0.0",
"household_id": "",
"subject_id": "",
"voice_runtime": "volc_hardware_agent",
"activation_state": "FACTORY"
}
```
Instance ID、Product Key、默认 Bot、传输模式和最低 SDK 由平台产品配置统一持有。设备第一次预配时自动生成绑定,运维创建设备或设备自身都不能覆盖这些字段。
响应中的 `data.credential.secret` 只显示一次。运维系统应立即通过受控注入工具写入设备安全区,不得复制到工单、聊天记录或普通数据库字段。
### 9.2 人工迁移激活状态
```json
{
"target_state": "CLAIMABLE",
"reason": "工厂质检通过",
"expected_revision": 1
}
```
并发控制:列表/详情返回的设备版本对应 `ETag`;修改和删除使用 `If-Match`。激活迁移单独使用 `expected_revision`。版本不一致返回 `412 VERSION_CONFLICT` 或 `409 DEVICE_ACTIVATION_CONFLICT`,刷新后再决定,不能强制覆盖。
## 10. 幂等、重试和本地持久化
| 数据 | 本地策略 |
|---|---|
| 平台 Device Secret | 安全区长期保存;轮换时原子替换 |
| 火山 Device Secret | 安全区长期保存;不要每次启动都预配 |
| `session_nonce` | 仅本次通话运行内存 |
| `tool_scope_token` | 仅本次通话运行内存 |
| 最终字幕待上传队列 | 授权允许时加密持久化,成功确认后删除 |
| 增量字幕/原始音频 | 默认不长期保存 |
重试规则:
- `Idempotency-Key` 长度 16—128 字符。
- HTTP 超时或 `retryable=true`:原业务正文 + 原幂等键 + 新 HMAC nonce/signature,指数退避。
- `401`:先检查平台 Device Secret、时钟、请求 URI、正文摘要和 Base64URL 编码,不要盲目轮换密钥。
- `409 IDEMPOTENCY_KEY_REUSED`:同一幂等键配了不同正文;这是客户端错误。
- `HARDWARE_SESSION_PROOF_INVALID`:丢弃当前会话内存数据,重新创建新会话,不尝试其他设备的 nonce。
- `action=stop_session`:优先停止音频和供应商连接,再做结束确认及本地队列收尾。
## 11. EchoEar 固件接入清单
出厂只写入:
- 平台 `device_id`;
- 平台 Device Secret;
- `hardware_id`;
- API Base URL 和服务器信任锚;
- 固件签名信任锚。
首次绑定后设备取得并安全保存:
- 火山 `instance_id`;
- 火山 `product_key`;
- 火山 `device_name`;
- 火山 Device Secret;
- `credential_version`。
绝不写入设备:
- 火山 Product Secret;
- 平台数据库主密钥/KMS 密钥;
- 其他设备的 Device Secret;
- 固定的会话 nonce 或工具 Token;
- 人设、长期记忆和聊天记录的永久明文副本。
会话侧必须做到:
- 每通先请求平台会话配置,再连接火山;
- 人设、近期摘要和长期记忆以本次 `session_config` 为准;
- 动态配置确认前不打开麦克风;
- 设备事件和最终字幕可靠回流平台;
- 平台下发 `stop_session` 时可在一个心跳周期内停止。
## 12. 当前实现边界
- 当前 API 已实现平台 HMAC、激活状态机、火山动态注册、v3 会话控制面、事件回流和最终字幕上传。
- 设备音频是设备直连火山,平台不代理每一帧音频;平台通过设备事件和字幕回流维护会话权威状态。
- Windows/EchoEar 客户端仍需按目标 SDK 完成真实音频 HAL、火山协议、断线重连和安全存储验收。
- `/device/v3/hardware-agent-license:ack` 处理器代码虽曾存在,但没有路由、没有生产契约,也不应由设备调用。
- 完整字段定义以 [`openapi/openapi.yaml`](../openapi/openapi.yaml) 为机器可读基准;本文件是设备研发和联调的实施说明。
## 13. 联调最小验收用例
1. 错误平台签名返回 `401`,日志不泄露设备是否存在。
2. 同 nonce 重放被拒绝;相同业务幂等键配新 nonce 可安全重试。
3. `CLAIMABLE` 设备不能预配,`BOUND` 设备可以预配但不能创建会话。
4. 首次预配成功取得火山一机一密;重复预配不重复注册。
5. Product Secret 在设备固件、NVS、日志和网络响应中均不存在。
6. `ACTIVE` 设备创建 v3 会话并得到正确 Bot ID、人设和授权上下文。
7. 未确认动态配置的 `session_started` 被拒绝。
8. 正常开始、心跳、最终字幕、结束全部回流平台。
9. 平台返回 `stop_session` 后设备关闭连接并补报结束。
10. 火山 License 额度耗尽时预配失败,扩容后使用原幂等请求可恢复;设备无需安装 License 或 ACK。
+55
View File
@@ -1,5 +1,21 @@
# 设备接入与启动配置联调说明
## 设备激活状态机
平台从V50开始独立维护 `activation_state`,不再用通用的 `device.status` 代替激活生命周期:
`FACTORY → CLAIMABLE → BOUND → PROVISIONING → ACTIVE → LOST/REVOKED`
- `FACTORY`:只有设备身份和平台HMAC凭据,生产质检完成后由运维放行。
- `CLAIMABLE`:可以生成一次性绑定码;设备可同步基础配置,但不能发起对话。
- `BOUND`:已经绑定家庭和长者,可以调用火山设备凭据预配接口。
- `PROVISIONING`:平台正在代设备调用火山 DynamicRegister 并保存一机一密;动态注册成功、有效绑定和设备凭据就绪后进入 `ACTIVE`。火山 License 是产品侧动态注册额度,不是设备安装包,也没有设备回执。
- `ACTIVE`:允许创建和上报对话。硬件智能体会话接口只接受该状态。
- `LOST`:设备身份认证、对话和火山运行凭据立即停用;核验归属和硬件身份后可恢复到 `ACTIVE`。
- `REVOKED`:终态。设备平台凭据、火山凭据、绑定和未使用绑定码全部吊销,不能恢复。
每次迁移都会增加 `activation_revision` 并写入 `device_activation_transition`。运维人工迁移接口为 `POST /api/v1/ops/devices/{id}/activation-transitions`,请求必须携带当前 `expected_revision`;用户认领、设备预配和删除由对应业务接口自动迁移,不应由客户端伪造状态。
## 1. 当前接口
| 方法 | 路径 | 作用 |
@@ -270,6 +286,41 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-sim-firmware
脚本会验证创建、首次激活、相同幂等键重放、Worker调用、两次签名回调去重和最终`ACTIVE`状态,并断言供应商仅启动一次。脚本只使用合成数据,验证结束后按本次通话编号精确清理数据库痕迹。
## 11.5 火山硬件对话智能体 v3 控制面
设备首次准备火山连接凭据时,先使用既有平台设备签名调用`POST /device/v3/hardware-agent-credentials:provision`并提交稳定`hardware_id`。平台从产品级配置解析Instance ID、Product Key、默认Bot、传输方式和最低SDK版本,自动建立当前设备的运行绑定;再从KMS或只读Secret文件加载Product Secret,以平台生成的Device Name代设备调用火山DynamicRegister。Product Secret永不到达设备。平台把返回的`device_secret`用独立的`VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY`执行AES-256-GCM加密后保存,只在设备认证且`Cache-Control: no-store`的响应中返回给同一设备。设备使用安全芯片、系统KeyStore或Windows Safe Storage保存这份一机一密,后续用于火山WebSocket签名。
火山硬件对话智能体 License 是产品侧可用额度。平台调用 DynamicRegister 时由火山自动消耗一份有效额度并返回对应设备的一机一密;设备不会收到 License 明文,也不需要提交安装回执。当前有效路由不包含`POST /device/v3/hardware-agent-license:ack`,预配响应也不包含`license`字段。平台可以维护额度监控或影子账本,但不得把自建池作为设备运行凭据来源。额度耗尽或到期时,应根据火山动态注册错误告警并扩容,扩容后重试原预配请求。
同一设备和Hardware ID重复调用时,平台解密返回既有一机一密,不重复注册;Hardware ID、产品或实例不一致时返回409,必须走受控轮换而不是覆盖。Product Secret、Device Secret明文不得进入日志、审计详情、错误响应、通话记录或测试报告。自动回归的mock密钥仅允许在隔离测试进程中注入,不得写入正式设备缓存。
设备期望配置中的`voice_runtime`为`volc_hardware_agent`时,不再调用`POST /device/v2/conversations`创建RTC房间。首次预配后设备要重新拉取配置版本,并读取平台发布的`minimum_sdk_version`执行本地预检;云端会以产品默认绑定中的同一版本再次门禁,过低时返回HTTP 426和`HARDWARE_AGENT_SDK_UPDATE_REQUIRED`。每次用户开始聊天必须先调用`POST /device/v3/agent-sessions`,不得复用上一次响应。创建请求必须声明SDK版本、输入/输出音频格式、传输模式、可靠字幕队列、动态会话配置、凭证轮换以及近/远端PCM录制能力。平台会在同一事务中冻结这些能力、有效授权、已发布人设、档案、最近三次已完成通话摘要和最多五条人工确认的长期记忆,并返回:
- `runtime.bot_id`、传输模式和仅本次有效的`session_nonce`;
- `session_config.dialog_id`,必须作为火山会话的业务对话标识;
- `system_prompt`、`persona_tone`和`authorized_context`,必须在开始发送音频前通过SDK动态配置或标准WebSocket的`session.update`注入;
- `config_digest`,用于设备日志和平台诊断比对,不得当作供应商凭据;
- 可选的`tool_url`和`tool_scope_token`,只允许配置为本次火山会话的只读记忆工具;令牌绑定本次通话和供应商会话引用,设备不得持久化或转给其他会话;
- 本次冻结的隐私策略和会话存活间隔。
设备收到火山`session.created`后必须先应用本次`session.update`,收到确认后才向`POST /device/v3/agent-sessions/{id}/events`提交`session_started`。开始事件必须同时提交火山实际会话关联号、`bot_id`、传输模式、平台原样返回的`config_digest`和`dynamic_config_applied=true`;任何一项不一致都不会进入`ACTIVE`,此时不得打开麦克风。通话期间按响应中的心跳间隔提交`session_updated`;响应`action=continue`时保持会话,收到`action=stop_session`时必须立即关闭火山连接,然后提交`session_ended`或`session_failed`。这样,家庭撤回授权、运营停话和设备吊销即使发生在设备直连火山期间,也会收敛到设备执行。
平台不是靠轮询火山控制台才知道通话状态:v3设备事件在同一事务内更新权威通话状态,既有数据库触发器随`ACTIVE/COMPLETED/FAILED`写入最小`web_event`,已登录SaaS页面通过认证SSE收到`conversation.started/completed/failed`并刷新列表。`session_updated`只续租并更新硬件事件水位,不向家庭页面逐心跳广播,避免每10秒放大为整页查询;运维诊断仍可读取最后事件时间和序号。
录音能力采用保守门禁:只有创建请求同时声明`near_pcm_recording=true`和`far_pcm_recording=true`时,平台才可能依据既有授权返回`transcript_and_audio`。缺任一能力都会把本次会话强制降级为`transcript_only`(没有字幕授权时为`none`),并把`record_near`、`record_far`、`upload_allowed`全部置为`false`,设备不得以混音或供应商未知格式冒充双轨录音。
最终字幕写入本地加密重试队列,使用`POST /device/v3/agent-sessions/{id}/turns:batch`上传。只上传用户最终识别事件和智能体`response.audio_transcript.done`,不得长期保存或上传增量字幕;同一`turn_key`或`sequence_no`重放必须保持正文完全一致。高质量传输可额外配置火山服务端状态/字幕回调作为旁路证据;低负载传输按火山当前约束没有该服务端回调,因此设备事件与终稿上传是强制能力,创建请求必须声明`transcript_upload=true`。
Product Secret只保存在平台KMS/Secret挂载中;平台注册结果中的`device_secret`在平台数据库加密保存,并只下发给对应设备的安全区或供应商SDK凭据存储。普通会话响应和日志不得包含该值。当前Windows标准WebSocket测试程序覆盖平台代理注册和真实低负载协议;高质量方案的火山硬件SDK二进制和板级音频HAL仍必须由目标芯片工程集成后执行真机验收。
本地控制面闭环可执行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-hardware-agent.ps1
```
脚本先证明过低SDK被HTTP 426拒绝、未完成动态配置不能激活,再验证产品默认Bot绑定、每次会话上下文预取、录音能力安全降级、开始事件、最终字幕、平台停话、`stop_session`下发、设备结束确认、能力快照和数据库状态收敛,并精确清理临时会话及绑定。
## 12. 错误处理
| HTTP状态 | 错误码 | 处理建议 |
@@ -279,6 +330,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-sim-firmware
| 503 | `DEVICE_AUTHENTICATION_UNAVAILABLE` | 退避后使用新随机数重试,不关闭鉴权 |
| 503 | `DEVICE_CONFIG_UNAVAILABLE` / `DEVICE_BOOTSTRAP_FAILED` | 退避重试并保留上一次有效配置 |
| 503 | `RUNTIME_CONFIG_UNAVAILABLE` | 生产环境没有命中活动语音路由;停止创建通话,由运营发布或恢复目标范围配置 |
| 426 | `HARDWARE_AGENT_SDK_UPDATE_REQUIRED` | 当前火山硬件智能体SDK低于设备绑定门禁;停止创建会话并升级固件/SDK |
| 503 | `HARDWARE_AGENT_PROVISIONING_FAILED` | 火山动态注册失败;用原幂等键、原正文和新签名退避重试,并由运维根据`request_id`检查产品License额度、有效期和火山原始错误 |
| 403 | `CONSENT_REQUIRED` | 不重试,等待有效授权 |
| 403 | `TRANSCRIPT_STORAGE_NOT_ALLOWED` | 停止补传,等待留存策略或授权恢复,不得本地绕过 |
| 409 | `DEVICE_NOT_BOUND` / `CONVERSATION_ALREADY_ACTIVE` | 修复绑定或查询已有通话 |
@@ -288,6 +341,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-sim-firmware
| 409 | `CONFIG_VERSION_CONFLICT` | 先重新获取设备配置再尝试创建新通话 |
| 409 | `PROVIDER_EVENT_CONFLICT` | 供应商事件编号复用且正文不同,转人工排查 |
| 409 | `CONVERSATION_STATE_CONFLICT` | 查询云端当前通话状态,不要强制覆盖状态机 |
| 401 | `HARDWARE_SESSION_PROOF_INVALID` | 丢弃本地会话,不得尝试其他设备或其他会话的nonce;重新创建会话 |
| 409 | `HARDWARE_AGENT_EVENT_CONFLICT` | 保留原事件和单调序号,查询当前会话状态并人工核对,不得覆盖 |
| 409 | `TRANSCRIPT_TURN_CONFLICT` | 保留本地终稿和冲突明细,禁止覆盖云端终稿,转入人工对账 |
| 409 | `TELEMETRY_EVENT_CONFLICT` | 保留冲突事件并停止推进本地确认点,人工核对事件键和单调序号 |
| 404 | `TELEMETRY_CONVERSATION_NOT_FOUND` | 删除错误通话引用,不得改用任意长者编号重试 |
+857
View File
@@ -0,0 +1,857 @@
# 设备注册到聊天记录上传接口文档
## 1. 设备请求平台时的请求头
运营后台和用户 App 调用 `/api/v1/*` 时使用:
```http
Authorization: Bearer <用户访问令牌>
```
设备调用 `/device/*` 时使用以下 HMAC 请求头:
| 请求头 | 中文含义 | 计算方式 |
|---|---|---|
| `X-Device-Id` | 平台设备编号 | 平台登记设备时返回的 `credential.device_id` |
| `X-Device-Timestamp` | 请求时间 | 当前 UTC 时间,RFC3339 格式,例如 `2026-09-05T09:20:00Z` |
| `X-Device-Nonce` | 防重放随机数 | 每个请求重新生成,长度 16–128 字符,不得重复 |
| `X-Device-Content-SHA256` | 请求正文摘要 | `hex_lower(SHA256(rawBody))`;GET 使用空字节正文 |
| `X-Device-Signature` | 请求签名 | `base64url_no_padding(HMAC-SHA256(platformDeviceSecret, canonical))` |
| `Idempotency-Key` | 业务幂等键 | 仅指定接口需要;同一次请求重试必须保持不变,长度 16–128 字符 |
| `Content-Type` | 正文类型 | 有 JSON 正文时为 `application/json` |
签名原文 `canonical`:
```text
{大写HTTP方法}\n{请求路径和查询参数}\n{X-Device-Timestamp}\n{X-Device-Nonce}\n{X-Device-Content-SHA256}
```
计算示例:
```text
body = UTF8('{"hardware_id":"echoear-a1b2c3"}')
bodySha256 = hex_lower(SHA256(body))
timestamp = "2026-09-05T09:20:00Z"
nonce = "d8b81b916ddd4f4584a80a82"
canonical =
"POST\n" +
"/device/v3/hardware-agent-credentials:provision\n" +
timestamp + "\n" +
nonce + "\n" +
bodySha256
signature = base64url_no_padding(
HMAC-SHA256(platformDeviceSecret, UTF8(canonical))
)
```
注意:
- 摘要必须针对实际发送的原始字节计算,签名后不能再次格式化 JSON。
- 查询参数属于签名内容,例如 `/device/v2/commands?limit=10`。
- GET 空正文 SHA-256 固定为 `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`。
- `platformDeviceSecret` 是平台注册时返回的设备密钥,不是火山 `Product Secret`,也不是火山 `device_secret`。
- 平台统一响应:成功为 `{"request_id":"...","data":...}`;失败为 `{"request_id":"...","error":...}`。
## 2. 完整流程
| 顺序 | 调用方 | 动作 | 接口/地址 | 结果 |
|---:|---|---|---|---|
| 1 | 运营平台 | 登记设备 | `POST /api/v1/ops/devices` | 返回平台设备 ID、平台设备密钥和绑定码 |
| 2 | 用户 App | 绑定设备 | `POST /api/v1/device-claims` | 状态变为 `BOUND` |
| 3 | 设备 | 获取火山运行凭据 | `POST /device/v3/hardware-agent-credentials:provision` | 平台动态注册火山设备,消耗一个 License,状态变为 `ACTIVE` |
| 4 | 设备 | 检查固件 | `GET /device/v2/bootstrap` | 得到 `firmware_compatible` |
| 5 | 设备 | 获取设备配置 | `GET /device/v2/config` | 得到配置版本和运行时配置 |
| 6 | 设备 | 判断是否继续 | `firmware_compatible=false` 时停止聊天并进入 OTA 流程 | 只有兼容固件可以继续 |
| 7 | 设备 | 创建对话会话 | `POST /device/v3/agent-sessions` | 获取 Bot ID、系统提示词、人设和授权记忆 |
| 8 | 设备 | 连接火山 | 火山 WebSocket + `session.update` | 注入本次人设并建立 16 kHz PCM 对话 |
| 9 | 设备 | 上报会话开始 | `POST /device/v3/agent-sessions/{id}/events` | 平台会话变为 `ACTIVE` |
| 10 | 设备 | 上传最终聊天文本 | `POST /device/v3/agent-sessions/{id}/turns:batch` | 用户和智能体最终文本入库 |
| 11 | 设备 | 上报会话结束 | 同一个 events 接口,`session_ended` | 平台会话变为 `COMPLETED` |
激活状态:
```text
FACTORY → CLAIMABLE → BOUND → PROVISIONING → ACTIVE → LOST/REVOKED
```
设备重置流程:
```text
停止语音 → 上传剩余聊天记录 → 上报session_ended → POST /device/v3/factory-reset
→ 平台解除绑定并暂停火山凭据 → 设备按local_actions清理 → 重启进入CLAIMABLE
```
## 3. 接口
### 3.1 登记设备
地址:
```http
POST /api/v1/ops/devices
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| `model` | string | 是 | 设备型号 |
| `hardware_revision` | string | 否 | 硬件版本 |
| `minimum_security_version` | string | 否 | 最低安全版本 |
| `minimum_firmware_version` | string | 否 | 最低固件版本 |
| `household_id` | UUID | 否 | 直接绑定的家庭编号;走绑定码时不传 |
| `subject_id` | UUID | 否 | 直接绑定的服务对象编号;走绑定码时不传 |
| `voice_runtime` | enum | 是 | 传 `volc_hardware_agent` |
| `hardware_agent_bot_id` | string | 否 | 兼容字段;当前方案不传,由平台产品配置决定 |
| `hardware_agent_transport` | enum | 否 | `low_load` 或 `high_quality`;建议由平台配置 |
| `hardware_agent_minimum_sdk_version` | string | 否 | 最低硬件智能体 SDK 版本 |
| `activation_state` | enum | 是 | `FACTORY`、`CLAIMABLE` 或 `BOUND`;一般传 `CLAIMABLE` |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `device.id` | UUID | 平台设备编号 |
| `device.status` | string | 设备业务状态 |
| `device.activation_state` | enum | 激活状态 |
| `device.activation_revision` | integer | 激活状态版本 |
| `device.model` | string | 设备型号 |
| `device.hardware_revision` | string/null | 硬件版本 |
| `device.minimum_security_version` | string/null | 最低安全版本 |
| `device.minimum_firmware_version` | string/null | 最低固件版本 |
| `device.voice_runtime` | enum | 语音运行时 |
| `device.version` | integer | 设备记录版本 |
| `credential.device_id` | UUID | 平台设备编号 |
| `credential.secret` | string | 设备调用平台所用的 HMAC 密钥,仅明文返回一次 |
| `credential.key_version` | integer | 平台设备密钥版本 |
| `credential.issued_at` | datetime | 密钥签发时间 |
| `claim.claim_code` | string/null | 一次性绑定码 |
| `claim.issued_at` | datetime/null | 绑定码签发时间 |
| `claim.expires_at` | datetime/null | 绑定码过期时间 |
示例:
```http
POST /api/v1/ops/devices
Authorization: Bearer <ops-token>
Content-Type: application/json
{
"model": "echoear-esp32s3",
"hardware_revision": "v1.0",
"minimum_firmware_version": "1.0.0",
"voice_runtime": "volc_hardware_agent",
"activation_state": "CLAIMABLE"
}
```
```json
{
"request_id": "req-register-001",
"data": {
"device": {
"id": "10000000-0000-0000-0000-000000000512",
"status": "registered",
"activation_state": "CLAIMABLE",
"activation_revision": 1,
"model": "echoear-esp32s3",
"voice_runtime": "volc_hardware_agent",
"version": 1
},
"credential": {
"device_id": "10000000-0000-0000-0000-000000000512",
"secret": "<platform-device-secret>",
"key_version": 1,
"issued_at": "2026-09-05T09:00:00Z"
},
"claim": {
"claim_code": "ABCD-EFGH-IJKL-MNOP",
"issued_at": "2026-09-05T09:00:00Z",
"expires_at": "2026-09-06T09:00:00Z"
}
}
}
```
### 3.2 用户绑定设备
地址:
```http
POST /api/v1/device-claims
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| `claim_code` | string | 是 | 一次性绑定码,不能填写设备 UUID |
| `household_id` | UUID | 是 | 家庭编号 |
| `subject_id` | UUID | 是 | 设备服务对象编号 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `binding_id` | UUID | 设备绑定关系编号 |
| `device_id` | UUID | 平台设备编号 |
| `household_id` | UUID | 家庭编号 |
| `subject_id` | UUID | 服务对象编号 |
| `bound_at` | datetime | 绑定时间 |
示例:
```http
POST /api/v1/device-claims
Authorization: Bearer <user-token>
Content-Type: application/json
{
"claim_code": "ABCD-EFGH-IJKL-MNOP",
"household_id": "10000000-0000-0000-0000-000000000211",
"subject_id": "10000000-0000-0000-0000-000000000411"
}
```
```json
{
"request_id": "req-bind-001",
"data": {
"binding_id": "10000000-0000-0000-0000-000000000611",
"device_id": "10000000-0000-0000-0000-000000000512",
"household_id": "10000000-0000-0000-0000-000000000211",
"subject_id": "10000000-0000-0000-0000-000000000411",
"bound_at": "2026-09-05T09:10:00Z"
}
}
```
### 3.3 占用 License 并获取火山设备凭据
地址:
```http
POST /device/v3/hardware-agent-credentials:provision
```
输入:
| 字段 | 位置 | 类型 | 必填 | 中文含义 |
|---|---|---|---:|---|
| `Idempotency-Key` | Header | string | 是 | 凭据申请幂等键,16–128 字符 |
| `hardware_id` | Body | string | 是 | 不随固件升级变化的物理硬件标识 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `instance_id` | string | 火山硬件智能体实例编号 |
| `product_key` | string | 火山产品标识 |
| `device_name` | string | 火山设备名称 |
| `hardware_id` | string | 已绑定的物理硬件标识 |
| `device_secret` | string | 火山设备专属密钥,需要加密保存 |
| `credential_ref` | string | 平台内部凭据引用 |
| `credential_version` | integer | 火山设备凭据版本 |
| `credential_source` | enum | `platform_dynamic_registration` 为新注册,`platform_stored` 为复用已有凭据 |
| `issued_at` | datetime | 凭据签发时间 |
示例:
```http
POST /device/v3/hardware-agent-credentials:provision
Idempotency-Key: provision-device-00000512-v1
Content-Type: application/json
<设备HMAC请求头>
{"hardware_id":"echoear-esp32s3-a1b2c3d4"}
```
```json
{
"request_id": "req-provision-001",
"data": {
"instance_id": "6a92945cc0c4689234aef656",
"product_key": "6a9568047dd4b23eacab9b1a",
"device_name": "dt-10000000000000000000000000000512",
"hardware_id": "echoear-esp32s3-a1b2c3d4",
"device_secret": "<volc-device-secret>",
"credential_ref": "volc-device-secret://10000000-0000-0000-0000-000000000512",
"credential_version": 1,
"credential_source": "platform_dynamic_registration",
"issued_at": "2026-09-05T09:12:00Z"
}
}
```
### 3.4 设备恢复出厂
地址:
```http
POST /device/v3/factory-reset
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| Header `Idempotency-Key` | string | 是 | 本次重置幂等键,16–128 字符;重试保持不变 |
| `reset_id` | UUID | 是 | 本次重置唯一编号 |
| `reset_counter` | integer | 是 | 设备安全存储中的单调递增重置次数 |
| `occurred_at` | datetime | 是 | 设备确认重置的 UTC 时间;不得晚于请求时间 5 分钟 |
| `trigger` | enum | 是 | `physical_long_press`、`device_menu` 或 `service_tool` |
| `firmware_version` | string | 是 | 当前固件版本 |
| `pending_turn_count` | integer | 是 | 未上传聊天记录数;必须为 `0` |
| `active_conversation_id` | UUID | 否 | 设备认为仍在运行的会话编号 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `reset_id` | UUID | 重置编号 |
| `device_id` | UUID | 平台设备编号 |
| `activation_state` | enum | 重置后固定为 `CLAIMABLE` |
| `reset_counter` | integer | 平台接受的重置次数 |
| `binding_removed` | boolean | 是否关闭了原用户绑定 |
| `previous_binding_id` | UUID/null | 原绑定关系编号 |
| `provider_credential_status` | enum | 火山设备凭据状态,通常为 `SUSPENDED` |
| `license_action` | enum | 固定为 `RETAINED`,重置不释放火山 License |
| `ended_conversation_count` | integer | 平台强制结束的残留会话数 |
| `cancelled_command_count` | integer | 平台取消的待执行命令数 |
| `config_revision` | integer | 重置后的设备配置版本 |
| `claim.code` | string | 新的一次性绑定码 |
| `claim.issued_at` | datetime | 绑定码签发时间 |
| `claim.expires_at` | datetime | 绑定码过期时间 |
| `local_actions` | string[] | 设备收到成功响应后必须执行的本地清理动作 |
| `reset_at` | datetime | 平台完成重置的时间 |
平台同时把旧用户侧的设备状态置为离线,并推送 `device.binding.reset` 事件。设备收到成功响应后,按 `local_actions` 清理本地火山凭据、用户上下文、会话与网络配置;设备平台身份和 HMAC 密钥不得删除。
示例:
```http
POST /device/v3/factory-reset
Idempotency-Key: factory-reset-00000512-0001
Content-Type: application/json
<设备HMAC请求头>
{
"reset_id": "10000000-0000-0000-0000-000000000901",
"reset_counter": 1,
"occurred_at": "2026-09-05T09:30:00Z",
"trigger": "physical_long_press",
"firmware_version": "1.1.0",
"pending_turn_count": 0
}
```
```json
{
"request_id": "req-reset-001",
"data": {
"reset_id": "10000000-0000-0000-0000-000000000901",
"device_id": "10000000-0000-0000-0000-000000000512",
"activation_state": "CLAIMABLE",
"reset_counter": 1,
"binding_removed": true,
"previous_binding_id": "10000000-0000-0000-0000-000000000611",
"provider_credential_status": "SUSPENDED",
"license_action": "RETAINED",
"ended_conversation_count": 0,
"cancelled_command_count": 0,
"config_revision": 9,
"claim": {
"code": "QRST-UVWX-YZ23-4567",
"issued_at": "2026-09-05T09:30:01Z",
"expires_at": "2026-09-06T09:30:01Z"
},
"local_actions": [
"clear_volc_credential",
"clear_user_context",
"clear_session_state",
"clear_network_credentials",
"reboot_to_claimable"
],
"reset_at": "2026-09-05T09:30:01Z"
}
}
```
设备必须保留平台 `device_id`、平台 HMAC `secret` 和 `hardware_id`;其余用户上下文、火山凭据、会话状态和网络配置按 `local_actions` 清除。
### 3.5 检查固件版本
地址:
```http
GET /device/v2/bootstrap
```
输入:
| 字段 | 位置 | 类型 | 必填 | 中文含义 |
|---|---|---|---:|---|
| `firmware_version` | Query | string | 是 | 当前固件版本 |
| `device_time` | Query | datetime | 是 | 设备 UTC 时间,RFC3339 |
| `board_rev` | Query | string | 否 | 硬件版本 |
| `rtc_sdk_version` | Query | string | 否 | 火山 SDK 版本 |
| `codec` | Query | string | 否 | 音频格式,例如 `pcm16` |
| `sample_rate` | Query | integer | 否 | 音频采样率,当前为 `16000` |
| `network` | Query | string | 否 | 网络类型,例如 `wifi` |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `server_time` | datetime | 平台 UTC 时间 |
| `clock_skew_ms` | integer | 平台时间减设备时间的毫秒差 |
| `device.device_id` | UUID | 平台设备编号 |
| `device.status` | string | 设备业务状态 |
| `device.model` | string | 设备型号 |
| `device.hardware_revision` | string/null | 硬件版本 |
| `device.minimum_security_version` | string/null | 最低安全版本 |
| `device.minimum_firmware_version` | string/null | 最低固件版本 |
| `device.firmware_compatible` | boolean | 当前固件是否允许继续聊天 |
| `device.config_revision` | integer | 当前平台配置版本 |
| `device.binding.bound` | boolean | 是否已绑定 |
| `device.binding.household_id` | UUID/null | 家庭编号 |
| `device.binding.subject_id` | UUID/null | 服务对象编号 |
| `device.binding.subject_name` | string/null | 服务对象名称 |
示例:
```http
GET /device/v2/bootstrap?firmware_version=1.0.0&board_rev=v1.0&rtc_sdk_version=1.1.0&codec=pcm16&sample_rate=16000&network=wifi&device_time=2026-09-05T09%3A15%3A00Z
<设备HMAC请求头>
```
```json
{
"request_id": "req-bootstrap-001",
"data": {
"server_time": "2026-09-05T09:15:01Z",
"clock_skew_ms": 1000,
"device": {
"device_id": "10000000-0000-0000-0000-000000000512",
"status": "registered",
"model": "echoear-esp32s3",
"minimum_firmware_version": "1.0.0",
"firmware_compatible": true,
"config_revision": 8,
"binding": {
"bound": true,
"household_id": "10000000-0000-0000-0000-000000000211",
"subject_id": "10000000-0000-0000-0000-000000000411",
"subject_name": "测试用户"
}
}
}
}
```
### 3.6 获取设备配置
地址:
```http
GET /device/v2/config
```
输入:
| 字段 | 位置 | 类型 | 必填 | 中文含义 |
|---|---|---|---:|---|
| `If-None-Match` | Header | string | 否 | 本地配置 ETag;未变化时返回 `304` |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| Response Header `ETag` | string | 当前配置 ETag |
| `device_id` | UUID | 平台设备编号 |
| `revision` | integer | 配置版本 |
| `desired_config` | object | 平台期望设备应用的配置 |
| `desired_config.voice_runtime` | enum | 语音运行时 |
| `desired_config.hardware_agent_transport` | enum | 火山传输方式 |
| `desired_config.minimum_sdk_version` | string | 最低 SDK 版本 |
| `reported_revision` | integer/null | 设备此前报告的配置版本 |
| `binding` | object | 当前绑定信息,字段与 bootstrap 相同 |
示例:
```http
GET /device/v2/config
If-None-Match: "7"
<设备HMAC请求头>
```
```json
{
"request_id": "req-config-001",
"data": {
"device_id": "10000000-0000-0000-0000-000000000512",
"revision": 8,
"desired_config": {
"voice_runtime": "volc_hardware_agent",
"hardware_agent_transport": "low_load",
"minimum_sdk_version": "1.1.0"
},
"reported_revision": 7,
"binding": {
"bound": true,
"household_id": "10000000-0000-0000-0000-000000000211",
"subject_id": "10000000-0000-0000-0000-000000000411"
}
}
}
```
### 3.7 创建会话并获取系统提示词
地址:
```http
POST /device/v3/agent-sessions
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| Header `Idempotency-Key` | string | 是 | 创建会话幂等键,16–128 字符 |
| `device_time` | datetime | 是 | 设备 UTC 时间 |
| `monotonic_ms` | integer | 否 | 开机后的单调时钟毫秒数 |
| `capabilities.hardware_agent` | boolean | 是 | 必须为 `true` |
| `capabilities.sdk_version` | string | 是 | 硬件智能体 SDK 版本 |
| `capabilities.transports` | string[] | 是 | 支持的传输方式 |
| `capabilities.input_audio_format` | string | 是 | 上行音频能力,当前为 `pcm_s16le_16000_mono` |
| `capabilities.output_audio_format` | string | 是 | 下行音频能力,当前为 `pcm_s16le_16000_mono` |
| `capabilities.server_event_callback` | boolean | 是 | 当前传 `false` |
| `capabilities.transcript_upload` | boolean | 是 | 必须为 `true` |
| `capabilities.dynamic_session_config` | boolean | 是 | 必须为 `true` |
| `capabilities.credential_rotation` | boolean | 是 | 必须为 `true` |
| `capabilities.near_pcm_recording` | boolean | 是 | 是否支持近端录音 |
| `capabilities.far_pcm_recording` | boolean | 是 | 是否支持远端录音 |
| `network.type` | string | 是 | 网络类型 |
| `network.rssi` | integer | 否 | Wi-Fi RSSI |
| `requested_goal` | string | 是 | 固定为 `open_conversation` |
| `config_revision` | integer | 是 | `/device/v2/config` 返回的配置版本 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `conversation_id` | UUID | 平台会话编号 |
| `status` | string | 正常为 `SESSION_READY` |
| `runtime_type` | string | `volc_hardware_agent` |
| `runtime.provider` | string | 服务商,当前为 `volcengine` |
| `runtime.bot_id` | string | 本次使用的 Bot ID |
| `runtime.transport` | string | 本次传输方式 |
| `runtime.provider_session_ref` | string | 火山会话引用 |
| `runtime.session_nonce` | string | 会话证明随机值 |
| `runtime.config_digest` | string | 本次动态配置摘要 |
| `runtime.wait_for_session_update` | boolean | 是否必须先应用动态人设 |
| `session_config.dialog_id` | string | 本次对话标识 |
| `session_config.system_prompt` | string | 系统提示词/核心人设 |
| `session_config.persona_tone` | string | 人设语气和表达风格 |
| `session_config.authorized_context` | string | 授权的用户背景、近期摘要和长期记忆 |
| `session_config.prompt_bundle_version` | string | 提示词版本 |
| `session_config.tool_url` | URL/null | 工具调用地址 |
| `session_config.tool_scope_token` | string/null | 本次会话工具令牌 |
| `privacy.retention_mode` | string | 数据保留模式 |
| `privacy.record_near` | boolean | 是否允许近端录音 |
| `privacy.record_far` | boolean | 是否允许远端录音 |
| `privacy.upload_allowed` | boolean | 是否允许上传聊天记录 |
| `session.engine_mode` | string | 会话引擎模式 |
| `session.heartbeat_interval_sec` | integer | 会话状态上报间隔 |
| `session.allocation_expires_at` | datetime | 会话分配过期时间 |
示例:
```http
POST /device/v3/agent-sessions
Idempotency-Key: session-device-00000512-0001
Content-Type: application/json
<设备HMAC请求头>
{
"device_time": "2026-09-05T09:20:00Z",
"monotonic_ms": 812345,
"capabilities": {
"hardware_agent": true,
"sdk_version": "1.1.0-echoear",
"transports": ["low_load"],
"input_audio_format": "pcm_s16le_16000_mono",
"output_audio_format": "pcm_s16le_16000_mono",
"server_event_callback": false,
"transcript_upload": true,
"dynamic_session_config": true,
"credential_rotation": true,
"near_pcm_recording": false,
"far_pcm_recording": false
},
"network": {"type":"wifi","rssi":-52},
"requested_goal": "open_conversation",
"config_revision": 8
}
```
```json
{
"request_id": "req-session-001",
"data": {
"conversation_id": "10000000-0000-0000-0000-000000000801",
"status": "SESSION_READY",
"runtime_type": "volc_hardware_agent",
"runtime": {
"provider": "volcengine",
"bot_id": "botM4gWQ9A9y",
"transport": "low_load",
"provider_session_ref": "provider-session-ref",
"session_nonce": "<session-nonce>",
"config_digest": "<config-digest>",
"wait_for_session_update": true
},
"session_config": {
"dialog_id": "dialog-00000801",
"system_prompt": "你是面向长者的陪伴助手……",
"persona_tone": "率真、自然",
"authorized_context": "经授权的背景和记忆……",
"prompt_bundle_version": "persona-v12"
},
"privacy": {
"retention_mode": "transcript_only",
"record_near": false,
"record_far": false,
"upload_allowed": true
},
"session": {
"engine_mode": "hardware_agent",
"heartbeat_interval_sec": 30,
"allocation_expires_at": "2026-09-05T09:25:00Z"
}
}
}
```
### 3.8 连接火山并注入人设
地址:
```text
wss://ai-gateway.vei.volces.com/v1/realtime?bot={bot_id}&wait_for_session_update=true
```
输入:
| 字段 | 位置 | 中文含义 | 来源/计算 |
|---|---|---|---|
| `bot` | Query | Bot ID | 创建会话返回的 `runtime.bot_id` |
| `X-Auth-Type` | Header | 鉴权类型 | 固定为 `1` |
| `X-Product-Key` | Header | 火山产品标识 | provision 返回的 `product_key` |
| `X-Device-Name` | Header | 火山设备名称 | provision 返回的 `device_name` |
| `X-Random-Num` | Header | 随机数 | 每次连接重新生成 |
| `X-Timestamp` | Header | Unix 秒 | 当前时间 |
| `X-Instance-Id` | Header | 火山实例编号 | provision 返回的 `instance_id` |
| `X-Hardware-Id` | Header | 物理硬件标识 | provision 使用的 `hardware_id` |
| `X-Signature` | Header | 火山鉴权签名 | `base64(HMAC-SHA256(device_secret, canonical))` |
| `session.update` | WebSocket JSON | 动态会话配置 | 注入平台返回的人设和记忆 |
输出:
| 事件 | 中文含义 |
|---|---|
| `session.created` / `session.updated` | 火山会话创建/动态人设生效 |
| `conversation.item.input_audio_transcription.completed` | 用户最终识别文本 |
| `response.audio_transcript.done` | 智能体最终回复文本 |
| 音频数据 | 智能体返回的 PCM16 音频,按 16 kHz 单声道播放 |
| error 事件 | 火山连接或会话失败 |
示例:
```text
canonical = "auth_type=1&device_name={device_name}&random_num={random_num}&product_key={product_key}&timestamp={timestamp}&instance_id={instance_id}"
X-Signature = base64(HMAC-SHA256(device_secret, canonical))
```
```json
{
"event_id": "evt-session-update-001",
"type": "session.update",
"session": {
"object": "realtime.session",
"model": "",
"input_audio_format": "pcm16",
"output_audio_format": "pcm16",
"config": {
"ASRConfig": {"TurnDetectionMode": 0},
"LLMConfig": {
"SystemMessages": [
"<system_prompt>\n表达风格:<persona_tone>\n授权上下文:<authorized_context>\n会话标识:<dialog_id>"
]
}
},
"agent_config": {"EnableConversationStateCallback": false}
}
}
```
### 3.9 上报会话事件
地址:
```http
POST /device/v3/agent-sessions/{conversation_id}/events
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| Path `conversation_id` | UUID | 是 | 平台会话编号 |
| `event_id` | string | 是 | 事件唯一编号 |
| `sequence_no` | integer | 是 | 事件顺序号,从 1 开始递增 |
| `event_type` | enum | 是 | `session_started`、`session_updated`、`session_ended` 或 `session_failed` |
| `occurred_at` | datetime | 是 | 事件发生时间 |
| `provider_session_ref` | string | 是 | 火山会话引用 |
| `session_nonce` | string | 是 | 会话证明随机值 |
| `provider_connection_ref` | string | 启动时必填 | WebSocket 连接标识 |
| `bot_id` | string | 启动时必填 | 实际连接的 Bot ID |
| `transport` | string | 启动时必填 | 实际传输方式 |
| `config_digest` | string | 启动时必填 | 已应用的人设配置摘要 |
| `dynamic_config_applied` | boolean | 启动时必填 | 必须为 `true` |
| `error_code` | string | 失败时填写 | 失败错误码 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `conversation_id` | UUID | 平台会话编号 |
| `status` | string | 会话状态,开始后为 `ACTIVE`,正常结束后为 `COMPLETED` |
| `action` | enum | `continue` 或 `stop_session` |
| `duplicate` | boolean | 是否为重复事件 |
| `accepted_at` | datetime | 平台接收时间 |
示例:
开始请求:
```json
{
"event_id": "device-event-start-001",
"sequence_no": 1,
"event_type": "session_started",
"occurred_at": "2026-09-05T09:20:03Z",
"provider_session_ref": "provider-session-ref",
"session_nonce": "<session-nonce>",
"provider_connection_ref": "ws-connection-001",
"bot_id": "botM4gWQ9A9y",
"transport": "low_load",
"config_digest": "<config-digest>",
"dynamic_config_applied": true
}
```
结束示例:
```json
{
"event_id": "device-event-end-001",
"sequence_no": 2,
"event_type": "session_ended",
"occurred_at": "2026-09-05T09:25:00Z",
"provider_session_ref": "provider-session-ref",
"session_nonce": "<session-nonce>"
}
```
响应示例:
```json
{
"request_id": "req-event-001",
"data": {
"conversation_id": "10000000-0000-0000-0000-000000000801",
"status": "ACTIVE",
"action": "continue",
"duplicate": false,
"accepted_at": "2026-09-05T09:20:03Z"
}
}
```
### 3.10 上传聊天记录
地址:
```http
POST /device/v3/agent-sessions/{conversation_id}/turns:batch
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| Path `conversation_id` | UUID | 是 | 平台会话编号 |
| Header `Idempotency-Key` | string | 是 | 本批记录幂等键,16–128 字符 |
| `turns` | array | 是 | 最终聊天记录数组,每批 1–500 条 |
| `turns[].turn_key` | string | 是 | 会话内唯一的轮次标识,最长 255 字符 |
| `turns[].sequence_no` | integer | 是 | 对话顺序号,会话内唯一且大于 0 |
| `turns[].speaker` | enum | 是 | 用户为 `subject`,智能体为 `agent` |
| `turns[].start_at` | datetime | 是 | 本轮开始时间 |
| `turns[].end_at` | datetime | 是 | 本轮结束时间,不早于开始时间 |
| `turns[].final_text` | string | 是 | 最终文本,最长 20000 字符 |
| `turns[].interrupted` | boolean | 是 | 本轮是否被打断 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `conversation_id` | UUID | 平台会话编号 |
| `accepted_count` | integer | 本次新写入的记录数 |
| `duplicate_count` | integer | 本次幂等重复的记录数 |
示例:
```http
POST /device/v3/agent-sessions/10000000-0000-0000-0000-000000000801/turns:batch
Idempotency-Key: turns-conversation-00000801-0001
Content-Type: application/json
<设备HMAC请求头>
{
"turns": [
{
"turn_key": "user-final-001",
"sequence_no": 1,
"speaker": "subject",
"start_at": "2026-09-05T09:20:10Z",
"end_at": "2026-09-05T09:20:12Z",
"final_text": "今天天气怎么样?",
"interrupted": false
},
{
"turn_key": "agent-final-001",
"sequence_no": 2,
"speaker": "agent",
"start_at": "2026-09-05T09:20:12Z",
"end_at": "2026-09-05T09:20:16Z",
"final_text": "我来帮你看看。",
"interrupted": false
}
]
}
```
```json
{
"request_id": "req-turns-001",
"data": {
"conversation_id": "10000000-0000-0000-0000-000000000801",
"accepted_count": 2,
"duplicate_count": 0
}
}
```
File diff suppressed because one or more lines are too long
@@ -0,0 +1,49 @@
{
"schema_version": 1,
"diagram_type": "sequence",
"meta": {
"title": "设备从注册到聊天结束的完整流程",
"locale": "zh-CN",
"output": "docs/diagrams/device-conversation-lifecycle.html",
"viewBox": [1080, 590],
"quality_profile": "showcase",
"column_fit": "spread"
},
"participants": [
{ "id": "ops", "type": "frontend", "label": "运维平台", "sublabel": "登记 / 放行" },
{ "id": "app", "type": "frontend", "label": "用户 App", "sublabel": "扫码绑定" },
{ "id": "device", "type": "external", "label": "EchoEar", "sublabel": "ESP32-S3" },
{ "id": "saas", "type": "backend", "label": "自有 SaaS", "sublabel": "设备控制面" },
{ "id": "volcIot", "type": "cloud", "label": "火山 IoT", "sublabel": "DynamicRegister" },
{ "id": "volcAgent", "type": "cloud", "label": "火山智能体", "sublabel": "实时对话" }
],
"segments": [
{ "from": 150, "to": 226, "label": "一、平台注册与绑定" },
{ "from": 232, "to": 310, "label": "二、首次预配(仅一次)" },
{ "from": 316, "to": 424, "label": "三、每次开始聊天" },
{ "from": 430, "to": 520, "label": "四、聊天与结束" }
],
"messages": [
{ "id": "register", "from": "ops", "to": "saas", "y": 166, "label": "登记设备,生成平台 Device Secret", "variant": "emphasis" },
{ "id": "inject", "from": "saas", "to": "device", "y": 194, "label": "安全注入平台身份(无 Product Secret)", "variant": "security" },
{ "id": "claim", "from": "app", "to": "saas", "y": 222, "label": "消费绑定码:CLAIMABLE → BOUND", "variant": "emphasis" },
{ "id": "provision", "from": "device", "to": "saas", "y": 250, "label": "请求 hardware-agent-credentials:provision", "variant": "emphasis" },
{ "id": "dynamicRegister", "from": "saas", "to": "volcIot", "y": 278, "label": "DynamicRegister:新设备消耗 1 个 License", "variant": "security" },
{ "id": "active", "from": "saas", "to": "device", "y": 306, "label": "加密保存并下发一机一密 → ACTIVE", "variant": "return" },
{ "id": "start", "from": "device", "to": "saas", "y": 334, "label": "POST /device/v3/agent-sessions", "variant": "emphasis" },
{ "id": "config", "from": "saas", "to": "device", "y": 362, "label": "返回 Bot、人设、授权上下文与 nonce", "variant": "return" },
{ "id": "connect", "from": "device", "to": "volcAgent", "y": 390, "label": "一机一密建连并注入 session.update", "variant": "security" },
{ "id": "started", "from": "device", "to": "saas", "y": 418, "label": "配置确认后上报 session_started", "variant": "emphasis" },
{ "id": "audio", "from": "device", "to": "volcAgent", "y": 446, "label": "双向音频 / 打断 / 工具调用", "variant": "default" },
{ "id": "sync", "from": "device", "to": "saas", "y": 474, "label": "session_updated + 最终字幕回流", "variant": "dashed" },
{ "id": "ended", "from": "device", "to": "saas", "y": 506, "label": "断开火山并上报 ended / failed → 摘要记忆", "variant": "emphasis" }
],
"activations": [
{ "participant": "saas", "from": 160, "to": 228, "type": "backend" },
{ "participant": "saas", "from": 240, "to": 312, "type": "backend" },
{ "participant": "volcIot", "from": 268, "to": 286, "type": "cloud" },
{ "participant": "saas", "from": 324, "to": 424, "type": "backend" },
{ "participant": "volcAgent", "from": 380, "to": 512, "type": "cloud" },
{ "participant": "saas", "from": 468, "to": 514, "type": "backend" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Archify visual-check · device-conversation-lifecycle.html</title>
<style>
*{box-sizing:border-box}body{margin:0;padding:24px;background:#e9eef5;color:#172033;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}header{max-width:1500px;margin:0 auto 18px}h1{margin:0 0 6px;font-size:20px}p{margin:0;color:#526176}.grid{max-width:1500px;margin:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;padding:10px;background:white;border:1px solid #c9d4e3;border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.08)}img{display:block;width:100%;height:auto;border:1px solid #e2e8f0}figcaption{padding:9px 4px 2px;color:#526176}@media(max-width:900px){.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<header><h1>Archify visual-check</h1><p>device-conversation-lifecycle.html · automated containment pass · visual review pending</p></header>
<main class="grid">
<figure>
<img src="device-conversation-lifecycle.visual-check.1440x900.light.png" alt="light 1440 by 900">
<figcaption><strong>LIGHT</strong> · 1440×900 · containment pass</figcaption>
</figure>
<figure>
<img src="device-conversation-lifecycle.visual-check.1440x900.dark.png" alt="dark 1440 by 900">
<figcaption><strong>DARK</strong> · 1440×900 · containment pass</figcaption>
</figure>
<figure>
<img src="device-conversation-lifecycle.visual-check.2048x1320.light.png" alt="light 2048 by 1320">
<figcaption><strong>LIGHT</strong> · 2048×1320 · containment pass</figcaption>
</figure>
<figure>
<img src="device-conversation-lifecycle.visual-check.2048x1320.dark.png" alt="dark 2048 by 1320">
<figcaption><strong>DARK</strong> · 2048×1320 · containment pass</figcaption>
</figure>
</main>
</body>
</html>
@@ -0,0 +1,547 @@
{
"schemaVersion": 1,
"ok": true,
"command": "visual-check",
"status": "pass",
"visualReview": "pending",
"artifact": {
"path": "E:\\Codex\\story\\platform\\docs\\diagrams\\device-conversation-lifecycle.html",
"sha256": "7fade308f989a6d95791c6d018dfcf4c5f50aafb8f6ce6d94c907943362eeea0",
"bytes": 715115
},
"state": {
"detail": "read",
"motion": "still"
},
"chrome": {
"status": "available",
"executable": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
},
"diagnostics": [],
"containment": {
"status": "pass",
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1376,
"diagramWidth": 1346,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1456,
"diagramWidth": 1426,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1602,
"diagramWidth": 1572,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1920,
"diagramWidth": 1870,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"readability": {
"status": "pass",
"minimumProjectedNodeTextPx": 6,
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1376,
"diagramWidth": 1346,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1456,
"diagramWidth": 1426,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1602,
"diagramWidth": 1572,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1920,
"diagramWidth": 1870,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"viewerChrome": {
"status": "pass",
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1376,
"diagramWidth": 1346,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1456,
"diagramWidth": 1426,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1602,
"diagramWidth": 1572,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1920,
"diagramWidth": 1870,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"captures": {
"status": "pass",
"screenshots": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1376,
"diagramWidth": 1346,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light",
"file": "device-conversation-lifecycle.visual-check.1440x900.light.png"
},
{
"width": 1440,
"height": 900,
"theme": "dark",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1376,
"diagramWidth": 1346,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "dark",
"file": "device-conversation-lifecycle.visual-check.1440x900.dark.png"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1920,
"diagramWidth": 1870,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light",
"file": "device-conversation-lifecycle.visual-check.2048x1320.light.png"
},
{
"width": 2048,
"height": 1320,
"theme": "dark",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1920,
"diagramWidth": 1870,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "dark",
"file": "device-conversation-lifecycle.visual-check.2048x1320.dark.png"
}
],
"contactSheet": "device-conversation-lifecycle.visual-check.html"
},
"sidecars": {
"receipt": "device-conversation-lifecycle.visual-check.json",
"contactSheet": "device-conversation-lifecycle.visual-check.html"
}
}
@@ -0,0 +1,29 @@
# EchoEar reboot history investigation — 2026-09-06
User reports loss of prior conversation context after power cycling and asks how the platform receives the conversation record.
## Existing path verified in source
- Device receives final subject transcription and agent audio transcript events, queues them outside the WebSocket receive callback, and POSTs to `/device/v3/agent-sessions/{conversation_id}/turns:batch`.
- Backend authenticates the bound device, checks transcript retention and consent, deduplicates final turns, and writes `conversation_turn`. The web app exposes these through Chat records → Conversation detail; API: `GET /api/v1/conversations/{id}/turns`.
- This path uploads text, not near/far audio recordings. Earlier device logs contain successful HTTP POSTs during exchanges but did not distinguish accepted transcript counts. New firmware logs accepted/duplicate counts without logging transcript content.
- Each new connection creates a fresh provider session. Authorized platform context is supplied through `session_config.authorized_context`, which firmware includes in `LLMConfig.SystemMessages`. Record storage and cross-session context are separate steps.
## Defects and local changes
1. Device omitted periodic session renewal. The server starts a lease on `session_started`, and `session_updated` renews it. Firmware now reads the server heartbeat interval, renews outside the audio callback using the existing reusable HTTP connection, and honors `stop_session`.
2. Recent summary selection excluded all `FAILED` conversations. It now accepts ready summaries from completed or failed conversations while preserving tenant/subject checks and existing consent gates. No raw transcript bypass or automatic long-term memory creation was added.
3. Hardware normal completion requested extraction generation 1; the extraction processor ignores a generation below the highest stored turn sequence. Hardware completion, reported failure, and server stop-timeout recovery now schedule the latest stored sequence with a deduplicated terminal extraction key. Existing extraction consent and content safety checks remain.
## Verification and pending work
**Device update 2026-09-06 10:20:** COM6 reconnected. Final heartbeat firmware `0x2ccf40` flashed with hash verification and booted to connected standby. The real user test session `157f866d-c364-4ae8-b169-50ca07024e5a` completed normally with 3 turns (including the preceding diagnostic sentence) and 2 READY summaries. Periodic heartbeats succeeded. Native Volcano callbacks were received for input and output, but contain only device identity and do not honor the attempted per-session callback override in this low_load test. callbacks memory3 validates these as device_probe without writing transcripts; device upload remains canonical. User has been asked to power-cycle and ask which joke was just told; actual recall outcome is pending. See the newest section of `volc-native-subtitle-callback.md`.
**Update 2026-09-06:** Supersedes the deployment/test limitations below. The user-supplied Desktop PEM successfully authenticated root. Backend `0.78.3-rc.subtitle-memory1` is now deployed; independent PostgreSQL regression tests actually passed (not skipped), including failed-session memory eligibility and terminal extraction generation. Production has stored transcripts and READY summaries (one failed session has 50 turns). All three backend readiness checks pass. Native callback protocol smoke checks also pass; real provider callbacks and device reboot recall remain unverified because COM6 is absent. The latest firmware build is `0x2ccf40`, still **not flashed**. See `volc-native-subtitle-callback.md` for deployment commands and outstanding checks. Earlier entries below are retained as investigation history.
- Firmware build passed: `firmware/echoear-platform-agent/build/voice-history-heartbeat-build.log`; binary size `0x2ccd50`.
- `go test ./internal/orchestration ./internal/httpapi ./internal/extraction` passed the available tests. New database regression cases cover failed/completed/active summary eligibility, other-subject exclusion, and extraction generation/deduplication. They were **skipped**, not validated against a database: `TEST_DATABASE_URL` is unset and Docker daemon is unavailable.
- **Firmware is not flashed.** COM6 disappeared after the user's power cycle. COM3 is a different USB device; COM4/5 are Bluetooth ports. Do not flash them.
- **Backend changes are not deployed.** Existing deployment key was rejected for `root@121.41.46.238`. Requested the actual SSH username; never request passwords/private key content.
- Awaiting USB reconnection and server access. Next verify actual transcript row counts, summary READY status, subject consent state, extraction job status, new context inclusion, and a real power-cycle recall test. Do not claim that online records or memory restoration have been verified.
- Last verified flashed app remains HTTP-reuse version `0x2cc8a0`; user confirmed continuous speech. Preserve its playback/display improvements and NVS during the next app-only flash.
+49
View File
@@ -0,0 +1,49 @@
# 长者自然陪聊 P0 基线与 P1 发布记录
日期:2026-09-06。
## 可恢复基线
- Git 开发前基线:`029a609`。
- 第一版里程碑:标签 `第一版` 指向 `0c2151a`;离线包为 `platform-第一版.bundle`。
- 改动前服务端版本:`0.79.0-rc.s2s-memory1`。
- 改动前真机固件:`1.1.0-echoear-s2s-memory.2`。
- 火山 Bot 完整配置在服务器受保护目录中另行备份,源码与本文不保存 Token、API Key 或设备密钥。
## 改动前实际链路
当前 EchoEar 使用火山硬件对话智能体的 `low_load` 传输和 S2S 混合输出模式。Bot 已配置端到端语音应用与爽快思思 2.0 音色,固定欢迎语为空;S2S 判停平滑窗口为 800 ms。平台已下发按绑定长者隔离的 Viking `MemoryConfig`,端到端语音凭据与 Viking API Key 均只在服务端保存。
核对生产数据库后发现,改动前没有任何已发布的 `PERSONA` 或 `AGENT_BUNDLE`。因此设备虽然收到档案、最近摘要和 Viking 记忆,完整的版本化陪聊人设并未进入实际会话。此前“你怎么才回来呢?我等你好久啦”来自更早的 Bot 欢迎语配置;当前查询结果已为空。
改动前连续上下文只读取最近会话的 `conversation_summary`,没有读取已生成的 `open_topic`。已确认记忆进入短上下文时每条仅保留约 30 字,容易丢失人物或结果。
## 已验证与后续观察
已经验证:
- Bot 的 S2S 配置、音色、Token 已配置状态和固定欢迎语状态均通过 `AibotQuery` 回读。
- Viking 原生记忆配置按稳定的长者标识隔离;此前已用只存在于 Viking 的偏好事实完成服务端检索验证。
- 生产库中目标长者有 28 轮最新内容整理结果,同时具备摘要和开放话题;P1 查询可直接使用这些现有数据。
- P1 后端、模拟器、管理端构建与受影响 Go 测试通过;固定 Agent 评测集的脱敏模拟观测为 10/10。
- 历史内容整理死信已复核:17 条没有长者话轮,13 条已有其他成功生成版本的摘要,不再存在“有长者原话但没有任何摘要”的遗漏。用一条真实失败任务发现并修复了空开放话题的保存约束,修复后该任务成功生成摘要并触发 Viking 同步。
- 真机确认流式语音已经连续,正常语速和语调下不再朗读“语气”等控制描述。
- 第一版陪聊规则在“年轻时在工厂修机器”的测试中仍连续追问,并补出了齿轮、车间和排查机器等用户没有说过的细节。
- 第二版把问题频率降到两轮一次,但仍用“吧”形成隐含问题,并生成“很厉害”“大家应该都很佩服您”等没有事实依据的评价。
- 第三版把“吧、吗、呢、是不是、有没有、会不会”等确认句一并计为问题,三次回复最多问一次;用户只说“是啊、嗯、对、不是”时,下一轮只能陈述并停住。同时禁止猜测场景、行动、情绪、结果和第三方态度。该版本在同一真机话题复测后,用户确认已没有上述问题。
- 最终固件 `1.2.2-echoear-companion.3` 已写入目标设备并完成启动验证:Wi-Fi、平台激活、屏幕、音频采集、唤醒词和语音引擎均正常就绪。
后续长期使用中继续观察:
- 重启后是否自然利用开放话题与 Viking 记忆,且不会突然照念旧线索。
- 更长对话中的抢话、用户打断恢复和故事线索收束仍需积累真实使用样本。
## P1 发布结果
- 服务端:`0.80.1-rc.companion2`;该修正版只在确有自然续聊线索时保存 `open_topic`。
- 最终真机固件:`1.2.2-echoear-companion.3`。
- 已发布配置组合:`elder-companion.bundle` 第三版,仅投放到当前真机绑定的长者档案;旧配置版本保留审计记录并已停止投放。
- 火山 Bot 固定欢迎语保持关闭,静态兜底人设与平台规则同步,判停平滑窗口进入 1200 ms 试验档。
- Bot 改动前完整配置备份:服务器 `/etc/data-terminal-cloud/voice-runtime/backups/companion1-aibot-before.json`,权限 `0600`。
P1 不增加故事线索状态表。开放话题暂时只是低优先级建议,是否已回答、拒绝、冷却或完成由后续 P2/P3 的结构化故事线索实现。
+220
View File
@@ -0,0 +1,220 @@
# 老人自然陪聊与人生故事:代码更改计划
日期:2026-09-06。规划基线:`029a609`;已保留的第一版里程碑:`第一版`(`0c2151a`)。P0 与 P1 已完成代码实现并发布服务端和版本化人设;真机固件是否已烧录及听测结果以同日基线记录为准。P2 至 P5 仍是后续计划。
## 1. 推荐方案与范围
保留 EchoEar → 火山硬件智能体的实时音频连接,继续使用 S2S 处理自然语音。平台在会话开始前提供简短、可追溯的陪聊背景;会话内首先由 S2S 顺着老人当下的话接续;后台从最终字幕整理故事线索,供下次聊天使用。Viking 继续承担历史记忆检索,平台数据库负责原话、故事版本和续聊状态。
第一阶段不要求每一句回复都等待平台另一个大模型做决定。“陪聊导演”先实现为会话前的背景选择器与会话后的话题整理器。只有实测需要更细控制、且火山当前传输模式支持安全更新时,再增加会话中引导。
交付目标:能自然接住老人当下的话,在对方愿意时聊深一件亲身经历;隔天或重启后能接续,已回答的问题和明确不愿聊的话题不反复开启。故事完整度是结果,不是要求老人完成的任务。
## 2. 已有代码与需要补齐的部分
以下按源码核对,旧设计文档与实际实现不一致时,以源码和运行验证为准。
| 已有模块 | 当前实现 | 本次扩展 |
| --- | --- | --- |
| `internal/orchestration/hardware_agent.go` | 创建会话、确定绑定长者、下发原生 Viking 过滤配置 | 加入陪聊背景选择,保持人物与授权复核 |
| `internal/orchestration/repository.go` | 读取已发布 PERSONA、档案、最近三次摘要和人工确认记忆,冻结会话配置 | 纳入开放话题、减少机械截断、保存本次选题依据 |
| `internal/extraction/generator.go` | 判断内容是否有价值、是否值得成故事,生成摘要、`open_topic` 和故事正文 | 提取具体事件、待续问题及其原话依据;允许没有后续问题 |
| `internal/extraction/processor.go` | 消费结束会话的最终字幕,保存故事候选、版本和来源 | 更新可跨会话的故事线索,精确关联字段与原话 |
| `internal/vikingmemory/` | AddSession 异步写入、事件/画像检索、按租户和长者生成稳定 user_id | 与故事线索保持一致;补齐自动记忆修正与删除的对账方案 |
| `internal/saas/content_service.go` | 故事查看、修订、确认、拒绝及记忆管理 | 复用这些流程,增加故事线索查询及暂停/关闭 |
| `web/src/pages/SaaSWorkspace.tsx` | 聊天记录、故事与记忆界面 | 展示未完故事、出处、继续/暂停状态 |
| `internal/opsconfig/`、`web/src/pages/OpsConfigWorkspace.tsx` | 人设与配置发布、评测、设备分组和回滚 | 发布陪聊人设及版本化策略,支持单设备试运行 |
| 设备与 Windows 模拟器 | 把会话背景和人设映射到 S2S 与 LLM,上传最终字幕 | 两端统一表达规则和新配置映射 |
已经确认的不足:
- `selectRecentConversationSummaries()` 只读 `summary_type='conversation_summary'`,未读已有的 `open_topic`。
- `buildContextSummary()` 将每条已确认记忆压缩到约 30 字,摘要压到约 140 字;关键人物和事件结果可能被截掉。
- `open_topic` 目前是一段模型文本,没有已回答、已拒绝、冷却、已完成等状态,无法可靠避免重复。
- 现有 `story_draft` 以通话和生成版本组织,缺少把多次通话串成同一故事的上层实体。
- 抽取器实际读取双方带角色的字幕;落库来源当前关联长者话轮,但不足以证明每条生成事实确实来自对应原话,不能仅凭角色标记防止模型补写。
- 固件与模拟器的提示词拼装不完全一致:固件有防止朗读舞台描述的规则,模拟器还将对话 ID 放入提示词正文。
- Viking 自动提取结果与平台人工确认记忆是两条路径;当前本地记忆删除不会自动删除 Viking 独立抽取的内容,不能把本地记忆页描述为完整 Viking 管理页。
## 3. 阶段 P0:记录基线、验证实际链路
在改体验前,保存当前 PERSONA/AGENT_BUNDLE、火山 Bot 配置和固件版本;秘密字段留在既有服务器秘密文件中,配置报告只展示非秘密字段。保留“第一版”标签,另建本次开发前的可恢复检查点。
新增 `docs/elder-story-companion-baseline.md`,记录以下实测:
1. 核验实际使用的 Bot、S2S 模式、声音、判停参数和动态人设。现有客户端允许初始更新发出后的 `session.created` 作为握手成功条件,不能仅由此证明 S2S、人设和记忆字段全部生效。
2. 做一次干净会话的 Viking 召回测试:所问事实只存在于 Viking,不能同时出现在最近摘要、平台人设或开场背景里。使用隔离测试身份,避免把测试事实写入老人档案。
3. 分别测普通闲聊、命中记忆、未命中记忆、被打断后继续四类场景。记录说完到首段声音、播放停顿、是否抢话、声音是否一致。
4. 核对生产内容整理任务:前次验证出现过一条内容抽取死信,同时 Viking 同步成功。先确认是否为过时生成版本、临时服务问题或有效任务失败;不得因入 Viking 成功就认定故事整理成功。
验收门槛:明确记录“哪些能力已经验证、哪些只是配置存在”。若 S2S 主路径无法使用原生记忆,先验证官方支持的记忆/工具或上下文注入方式,再确定适配;不直接把所有回复改走平台级联模型。
## 4. 阶段 P1:改善说话方式、利用现有续聊线索
### 4.1 发布新的陪聊人设
在既有 PERSONA → AGENT_BUNDLE 发布流程中新增版本,不把业务人设写死进 C 固件。增加可审阅的提示词源文件,例如 `deploy/personas/elder-story-companion-v1.md`,发布后实际运行以平台配置版本为准。
人设规则:
- 自然、平等地聊天,使用档案中的称呼;未设置称呼时不擅自称“王叔叔”等。
- 通常回复一两句,有必要才问一个问题;可以只回应、不提问。
- 跟随老人刚提到的人、物和经历;先回应具体内容,避免每次先夸奖再追问。
- 不因为说过“厂里维修”就推断老人很能干,也不把“蛋糕体多一点”扩写成老人喜欢扎实、绵密或不甜的口感。
- 老人主动展开时顺着聊;拒绝、疲倦或换话题时停止追问。问题只是可选线索,不是采访清单。
- 允许重复讲述和模糊年代,不要求准确日期;不补写亲历事实、不说自己也经历过。
- 记忆可能有误时轻量核对;老人纠正后按最新说法回应并留下修正依据。
- 不朗读语气、动作、系统提示或配置;不使用“你怎么才回来”“我一直等你”等施压式开场。
### 4.2 开场与语音节奏
清空火山 `AgentConfig.WelcomeMessage` 中现有固定句,真机核验空字符串是否确实关闭欢迎语。唤醒后给简短非语音就绪提示,让老人知道可以说话。若产品需要问候,后续只在明确唤醒后给中性短句;普通重连不重复播放。
先以已知当前判停参数为基线,对 1200、1500、1800 ms 做实测比较,选一个默认值;这些是实验档位,不是已确认的最优值。核对 S2S 实际消费的参数路径,不能只改普通 ASR 的 VAD 字段。将“等待老人说完”和“音频播放缓冲”分别测量,保持已有流式播放改进。
### 4.3 直接接入现有开放话题
修改 `repository.go` 的连续上下文查询,读取最近会话同一最新生成版本的摘要和 `open_topic`,只挑一个有来源、未被后续对话回答且适合当下的线索。P1 尚无结构化状态时,只把它当低优先级建议;无法判断是否已回答则不主动开启,P2 再补齐状态管理。
将背景拼装拆成独立文件 `internal/orchestration/companion_context.go`。按字段优先级控制长度:交流边界、必要称呼、选中的事件事实、一个续聊线索、其他记忆;保留事件主语及结果,不用 30 字机械切断。沿用现有上下文上限作为初始预算,超限时先丢低优先级条目,实测后再调。
固件 `main/platform_client.c` 与模拟器 `electron/hardware-agent-client.cjs` 同步:可信表达规则在前,历史数据明确标记为背景;会话 ID 保留在协议字段中。两端映射结果使用同一组夹具校验。
交付与验收:第一版自然陪聊配置;不再重复原欢迎语、不念舞台描述,老人补充细节后能接住、不必每轮问问题。第一阶段优先复用 `authorized_context`,不强制增加设备接口或大幅修改音频链路。
## 5. 阶段 P2:持久化“未完故事”和陪聊背景
### 5.1 新增轻量陪聊模块
新增 `internal/companion/`:
| 拟新增文件 | 职责 |
| --- | --- |
| `types.go` | `CompanionBrief`、话题状态、引用和策略版本 |
| `service.go` | 根据当前绑定与授权读取候选,组装本次背景 |
| `selector.go` | 排除不愿聊/已回答话题,优先选择相关未完故事,控制重复 |
| `repository.go` | 固定租户和长者范围的读写,版本检查 |
| `render.go` | 将结构化背景渲染为短上下文,按字段预算取舍 |
`CompanionBrief` 建议包含:称呼、当前交流偏好、一个可选故事线索、最多三条有来源的事实、已回答或不宜再问的事项、来源版本、策略版本与生成时间。事实与“可选追问”必须分开,候选问题不能作为已发生事实保存。
会话前只做数据库选择和渲染;需要模型理解的工作在后台提前完成。缓存未命中时退化为现有档案和就绪摘要。新背景冻结进 `context_snapshot`,引用/版本纳入 `content_digest` 和硬件 `config_digest`;旧设备仍可读取文本形式的 `authorized_context`。
### 5.2 数据库增量
从下一空闲迁移编号开始;当前最新为 000052,可暂拟 `000053_companion_story_threads`,实施时重新核对。
| 拟新增表/字段 | 内容 |
| --- | --- |
| `story_thread` | 租户、长者、标题、主题类别、`OPEN/PAUSED/CLOSED`、版本、最后聊到时间、下次可主动提及时间 |
| `story_thread_source` | 将同一故事关联多次会话、原话和既有故事版本,引用均检查相同租户/长者 |
| `story_thread_question` | 问题、提出依据、`PROPOSED/ASKED/ANSWERED/DECLINED`、提问及回答的源话轮 |
| `context_snapshot` 扩展 | 本次背景 JSON、策略版本、实际选择的线索及引用,供回放和排查 |
`PAUSED` 表示不主动开启;老人主动提起时按当下意愿回应。`DECLINED` 的问题不能换个说法反复追问。`CLOSED` 表示当前故事暂告一段落,不等于删除,也不表示以后禁止重讲。
数据库以租户+长者为记忆范围,不以设备作为唯一记忆身份。保留现有 `u_=SHA256(tenant:subject)` 的稳定映射,不因为人设升级更换 assistant_id。多台设备绑定同一长者共享该长者的记忆;绑定不同长者严格隔离。
接入位置:`internal/app/app.go` 装配模块;`hardware_agent.go` 准备背景;`repository.go` 在短事务中重新核对绑定、授权、版本并冻结。模型请求和 Viking 请求不能置于数据库长事务内。
验收:相同用户重复建立会话不反复问已回答的问题;“这个不想聊”会暂停主动提及;换绑另一个长者不读取原长者线索;后台不可用时仍能基本聊天。
## 6. 阶段 P3:把聊天整理成可回访、可修订的故事
扩展现有 `GenerationResult`,增加可选的事件要素、人物、时间原文、地点、已回答问题和后续问题。全部为版本化结构化结果,兼容旧数据,不要求一次聊天把所有字段填满。
必须让每个事实字段包含真实的 `source_turn_ids`,必要时包含文本偏移。双方字幕可帮助解释“蛋糕多一点的”这样的回答,但助手自己的叙述不能成为老人经历的事实来源。年份不确定则保留“刚参加工作那会儿”,不推算为确定年份;模型推测的情绪不得保存为老人自述。
代码任务:
1. `internal/extraction/generator.go` 升级输出结构和提示词;支持有效摘要但没有故事、也没有开放话题的结果。
2. `internal/extraction/processor.go` 校验来源引用、生成版本和当前授权;保存既有摘要/故事修订,并发布 `conversation.companion_update_requested`。
3. `internal/companion/processor.go` 后台归并相同故事、更新问题状态。按会话+生成版本去重,避免迟到字幕和重试造成重复故事;不覆盖人工修订。
4. 需要时用下一迁移扩展 `story_revision` 的结构化要素和来源;继续复用 `story_draft/story_revision/story_source`,不另建一套重复的故事正文系统。
断电路径保留“启动时补报旧会话”和服务端超时恢复。最终字幕先可靠保存;故事和 Viking 抽取异步完成。入库尚未就绪时,下一会话使用已保存且授权允许的近期上下文,不能承诺断电后一秒内所有新记忆都已可检索。
验收:蓝莓蛋糕偏好只生成偏好/摘要,不硬凑一篇人生故事;同一工厂经历跨三次聊天归入同一线索;“我刚说错了”留下修订关系,历史原话可追溯;重放及迟到事件不重复创建内容。
## 7. 阶段 P4:Viking、平台界面与修正保持一致
Viking 的事件/画像继续用于检索,故事的状态、版本与原话出处以平台数据库为准。初期使用既有 `event_v1/profile_v1`;平台自己保存续聊状态,不把所有内部问题列表都写进可召回记忆,避免模型把“计划问什么”误认为老人说过什么。
新增规则或同步故事摘要前,先测试抽取覆盖、重复与删除语义。现有普通用户原话 → Viking AddSession 路径和人工确认记忆路径要明确标识;同一段故事避免同时多次写入。
当前 Viking 与本地记忆删除不同步,需增加 `provider_ref`/来源会话映射以及修订、停用、删除任务。调查 Viking 的单条修订/删除接口,核验是否返回稳定引用;如果只能按会话重建,必须先设计排除与重建策略。无法可靠同步时,界面明确显示尚未完成,不宣称外部记忆已删除;老人纠正的内容也不能继续被旧自动记忆覆盖。
复用 `internal/saas/content_service.go` 和既有故事审核权限。建议新增:
- `GET /api/v1/subjects/{id}/story-threads`:未完/暂停/已结束线索。
- `GET /api/v1/story-threads/{id}`:事件要素、已聊内容、待续问题、原话来源。
- `PATCH /api/v1/story-threads/{id}`:修正标题、暂停或关闭;沿用 ETag、原因与现有权限模型。
以上为拟新增接口,统一在 `internal/httpapi/` 与 `openapi/openapi.yaml` 定义,生成 `web/src/api/schema.d.ts`,不手改生成文件。
前端在 `SaaSWorkspace.tsx` 现有故事区域增加“可以接着聊”“暂时不主动聊”“整理好的故事”;将新增部分拆到独立组件,避免继续扩大单文件。展示每张卡片的来源和是否经确认,家属查看及分享沿用已有授权规则。Viking 同步状态属于管理侧诊断信息,不进入老人语音表达。
## 8. 阶段 P5:需要时再做会话内策略更新
P1–P4 稳定后再决定是否需要。只在老人换话题、明确拒绝或讲完一个片段时触发,后台分析最终字幕,生成有有效期的简短提示;不以固定每句调用一个模型为默认方案。
前置验证:当前 low_load + S2S 是否支持会话中更新、哪些字段可变、会不会重置对话或影响音频。需要修改时关注:
- `firmware/echoear-platform-agent/components/volc_conv_ai/src/transports/low_load/src/volc_ws.c`
- `firmware/echoear-platform-agent/components/application/service/conv_service/src/volc_conv.c`
- `internal/orchestration/transcript_batch.go`、`native_subtitle.go` 和硬件会话事件接口。
策略消息带 conversation_id、revision、based_on_turn_sequence、过期时间和确认结果;在音频回调外处理。过时消息不能覆盖新话题。若当前供应商协议不支持合适的更新,保留会话前背景+会话后整理,记录限制;不得用重新建连模拟逐轮控制。
## 9. 测试、发布与回滚
### 自动验证
- 运行受影响 Go 模块的单元测试;涉及来源、幂等、状态及授权的场景必须接真实隔离 PostgreSQL/Redis,记录跳过的测试,不能把跳过算通过。
- 固件和模拟器用同一配置夹具校验 S2S/LLM 规则一致、秘密不入响应、长背景可控、旧设备兼容;新增字段才需要重新编译及烧录。
- 数据库迁移在独立测试库验证升降级;生产采用可兼容旧代码的增量迁移。
- 扩展 `testdata/agent-eval-suite.v1.json` 或另建 v2,复用 `internal/agenteval/`。语音自然度必须真机听测,不能只检查提示词或生成的文本。
### 真机验收用例
| 场景 | 合格行为 |
| --- | --- |
| “今天没什么事,就坐了一会儿” | 能平常回应,不强行提取故事、连续追问 |
| “我以前在厂里修过机器” | 跟住具体话题,每次最多一个自然追问,允许没有追问 |
| 说到一半停顿、接着说 | 在选定判停档位下不过早抢话;误判可被打断 |
| “这个不想聊,换一个” | 当轮转向,下次也不主动重开该线索 |
| 上次讲修车床,下次又见面 | 能接续有来源的具体细节,不假装知道未讲完的内容 |
| “不是师傅,是我同事” | 后续使用修正说法,不把旧记忆重新当事实 |
| 一次闲聊中断电并恢复 | 已收到的字幕保留,后台收尾,记忆就绪后可再次检索 |
| 更换绑定长者 | 不串人物、偏好或故事 |
| 抽取或 Viking 暂时不可用 | 实时聊天继续,后台可重试,缺少记忆时不编造 |
先录制相同脚本的基线与新版对照。普通接话、故事接续、记忆检索分别统计首段声音 P50/P95;另记播放空洞、抢话、重复问题、无来源事实和用户主动讲述的片段数。建议以 20 组脚本作首轮检查:跨人记忆、忽视明确拒绝、测试中的虚构个人事实须为零;语音延迟与连续性不得明显差于基线。主观自然度由实际听测反馈决定,不以“说满三分钟”强迫老人配合。
### 发布顺序
1. P0 基线验证。
2. P1 人设、开场、现有 open_topic 接入,先针对当前 EchoEar;称为“自然陪聊试用版”。
3. P2 数据结构与背景选择器 → P3 后台故事线索更新;称为“故事续聊版”。
4. P4 平台管理、Viking 修正对账;再交付“故事整理版”。
5. P5 按测得的问题决定,不作为首版前置条件。
利用现有配置版本和设备组发布能力。每批保存 API/worker 镜像、固件二进制、Bot 非秘密配置报告及受保护的完整备份。新逻辑通过版本化策略启停,读写保持向后兼容;回滚应用不回滚生产内容数据。第一版里程碑保留不动。
## 10. 首批建议实际改动清单
本计划执行时,第一批只落地 P0+P1,下列任务可形成一个可审阅提交组:
1. 新增基线记录与陪聊人设源文件,通过既有人设版本发布。
2. 修改 `repository.go` 并提取 `companion_context.go`,接入现有 `open_topic`、改进背景选择与截断。
3. 同步固件和模拟器的提示词构造;核验欢迎语关闭和判停设置。
4. 添加有意义的回归用例:开放话题来自同一长者和同一最新摘要版本、来源为助手的事实不能当用户经历、超长背景保留关键字段、撤回授权后不注入。
5. 受影响测试通过后,部署当前设备试用,用同一组语音脚本比较体验,再推进结构化故事线索。
## 官方依据与待验证项
- [AibotQuery:S2SConfig 与 MemoryConfig](https://www.volcengine.com/docs/6348/1807452?lang=zh):确认可配置字段与模式定义。配置存在不等于当前真实链路全部生效,P0 以实测补齐。
- [RTC 接入 Viking 长期记忆](https://docs.volcengine.com/docs/84313/1928352?lang=en):检索结果用于增强模型上下文;故事续聊状态仍需要平台维护。
- [Viking AddSession](https://www.volcengine.com/docs/84313/1783353?lang=zh):提交会话消息后按抽取配置形成事件/画像;提交成功与可检索就绪分开验收。
本文中的 P2 至 P5 新表、接口和发布阶段仍是建议设计,尚未实施。P0/P1 的实际结果见 `docs/elder-story-companion-baseline.md`。
+23
View File
@@ -138,3 +138,26 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-firmware-rol
A/B启动确认批次在OpenAPI 0.62.0下扩展可选回执阶段,不增加旧设备必填字段或主状态枚举。启用设备先将制品写入非活动分区并持久化启动尝试,再依次上报`staging`、`reboot_pending`和`boot_verifying`;健康通过才提交成功,健康失败或超时则恢复旧分区并提交`FAILED/rolled_back`及回退版本。云端将三类中间阶段保持为`INSTALLING`,同时保留完整`last_result`供页面展示。低强度验证只执行定向Go用例、前端类型/生产构建和一次B分区启动失败自动回A分区的纵向冒烟,不把模拟结果作为真实Bootloader验收。详细协议见[设备A/B分区固件升级与启动确认协议](device-firmware-ab-protocol.md)。
OpenAPI 0.63.0和V38进一步为每台固件分配保存去重的阶段事件。直接命令ACK与批量遥测补交在更新命令/分配的同一事务内写入事件,重复结果摘要通过SHA-256唯一键保持幂等;批次详情按设备返回完整时间线,页面显示云端接收时间、阶段、命令状态和稳定结果码。`cmd/firmware-conformance`可把该时间线与设备侧Bootloader/分区证据进行离线一致性校验,详细格式和15项检查见[固件真机联调一致性证据采集与校验说明](firmware-conformance-evidence.md)。
## 9. 维护端租户级固件管理
V46和OpenAPI 0.74.0在保留家庭端固件历史及设备下载协议的前提下,引入维护端租户级固件制品与OTA批次。维护端创建的`firmware_release.household_id`和`firmware_rollout.household_id`为空,`created_source=OPS`;家庭端已有记录继续使用原家庭范围,二者共享租户、型号、硬件版本和业务版本唯一性,避免同一租户发布相互冲突的活动制品。
维护中心“固件管理”页面支持选择客户组织、登记或吊销可信公钥、浏览器计算SHA-256后直传私有对象、恢复中断上传、完成签名校验、暂停、恢复、退役和人工下载。人工下载与设备下载都只签发短期私有地址;人工下载额外记录`ops.firmware.release.download`审计事件,设备下载继续要求设备身份、目标批次、版本状态和分配状态全部通过。
维护端接口使用`X-Tenant-Id`固定客户组织边界:
| 方法与路径 | 用途 |
|---|---|
| `GET/POST /api/v1/ops/firmware-signing-keys` | 查询或登记租户可信公钥 |
| `POST /api/v1/ops/firmware-signing-keys/revoke` | 在引用保护通过后吊销公钥 |
| `GET/POST /api/v1/ops/firmware-releases` | 查询租户固件台账或创建租户级上传记录 |
| `POST /api/v1/ops/firmware-releases/{id}/upload-session` | 恢复未完成的私有上传 |
| `POST /api/v1/ops/firmware-releases/{id}/finalize` | 校验对象和签名并发布版本 |
| `POST /api/v1/ops/firmware-releases/{id}/pause` | 暂停版本分发和关联在途批次 |
| `POST /api/v1/ops/firmware-releases/{id}/resume` | 恢复版本分发,不自动恢复旧批次 |
| `POST /api/v1/ops/firmware-releases/{id}/retire` | 永久退役版本 |
| `GET /api/v1/ops/firmware-releases/{id}/download` | 获取带摘要、签名和过期时间的短期人工下载地址 |
| `GET/POST /api/v1/ops/firmware-rollouts` | 查询OTA批次或按兼容设备和灰度比例创建升级批次 |
| `GET /api/v1/ops/firmware-rollouts/{id}` | 查询逐设备下载、安装和确认进度 |
| `POST /api/v1/ops/firmware-rollouts/{id}/pause` | 暂停批次并取消尚未投递的升级命令 |
@@ -84,6 +84,10 @@ docker run --rm -v "${PWD}:/src" -w /src golang:1.26.6-alpine `
npm --prefix web audit --audit-level=high
docker run --rm -v "${PWD}:/src" zricethezav/gitleaks:v8.30.1 `
detect --source=/src --config=/src/.gitleaks.toml --no-banner
docker run --rm -v "${PWD}:/src" `
-v data-terminal-trivy-cache:/root/.cache/trivy aquasec/trivy:0.74.0 `
config --ignorefile /src/.trivyignore.yaml --exit-code 1 `
--severity HIGH,CRITICAL /src
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock `
-v data-terminal-trivy-cache:/root/.cache/trivy aquasec/trivy:0.74.0 `
image --scanners vuln --ignore-unfixed --severity HIGH,CRITICAL `
+412
View File
@@ -0,0 +1,412 @@
# 首次接入接口字段清单
由 `node scripts/generate-onboarding-contract.mjs` 根据实际Go请求结构生成。共 31 项操作。完整响应结构见 [OpenAPI](../openapi/onboarding.yaml),流程、签名、错误恢复见 [实施说明](onboarding-implementation.md)。
## C01 校时挑战
`POST /api/v1/device/time-challenges`
调用身份:校时请求,无请求签名;设备必须核验TIME1响应MAC
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| device_id | string | 是 | 生产编号,KP01-加12位大写MAC;不是平台UUID |
| key_version | integer | 是 | 设备认证密钥版本 |
| boot_id | string | 是 | 每次启动生成并保持的UUID v4 |
| client_nonce | string | 是 | 设备生成的16字节随机数,Base64URL无填充 |
响应:根对象直接返回 `{payload_b64,mac_hex}`。
## C02 首次认证和启动注册
`POST /api/v1/device/register`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| challenge_id | string | 是 | C01签名校时返回的一次性挑战 |
| boot_id | string | 是 | 每次启动生成并保持的UUID v4 |
| hardware_model | string | 是 | 生产登记型号,当前为KP01 |
| hardware_revision | string | 是 | 硬件修订号,注册必须与库存一致 |
| firmware_version | string | 是 | 固件版本,用于兼容诊断 |
| sdk_version | string | 是 | SDK版本,用于兼容诊断 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `RegisterResponse.data`。
## C03 创建临时设置会话
`POST /api/v1/device/setup-sessions`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| boot_id | string | 是 | 每次启动生成并保持的UUID v4 |
| local_setup_id | string | 是 | 设备本次设置入口UUID v4;重试保持不变 |
| transport_profile | string | 是 | 固定LOCAL_WEB,表示设备提供本地网页入口 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `SetupResponse.data`。
## C04 获取一次性手机入口
`POST /api/v1/device/handoffs`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| setup_id | string | 是 | 云端15分钟临时设置会话UUID |
| local_setup_id | string | 是 | 设备本次设置入口UUID v4;重试保持不变 |
| code_challenge | string | 是 | SHA256(ASCII verifier)的Base64URL无填充值 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `HandoffResponse.data`。
## C05 查询状态和命令
`POST /api/v1/device/poll`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| setup_id | string | 是 | 云端15分钟临时设置会话UUID |
| boot_id | string | 是 | 每次启动生成并保持的UUID v4 |
| known_revision | integer | 是 | 设备已知状态版本;允许0 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `PollResponse.data`。
## C06 物理按键确认绑定
`POST /api/v1/device/claim-confirmations`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| claim_id | string | 是 | 待物理确认的绑定申请UUID |
| challenge | string | 是 | C05给设备的短时按键挑战,手机不能获取 |
| binding_digest | string | 是 | 云端生成的绑定摘要,按键回报原值 |
| expected_binding_generation | integer | 是 | 提交时预期绑定代数,防止跨解绑或重新绑定重放 |
| press_event_id | string | 是 | 该次真实物理按键事件UUID v4,重试保持不变 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ConfirmationResponse.data`。
## C07 绑定状态持久化回执
`POST /api/v1/device/state-acks`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| device_revision | integer | 是 | 云端设备状态版本号 |
| binding_generation | integer | 是 | 绑定代数;解绑、重新绑定后旧代数无效 |
| local_generation | integer | 是 | 本次启动的本地持久化序号,不能倒退 |
| state_hash | string | 是 | STATE1规范字符串的SHA256小写十六进制摘要 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `StateAckResponse.data`。
## C08 申请设备执行许可
`POST /api/v1/device/activation-attempts`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| activation_id | string | 是 | 该物理设备唯一的激活任务UUID |
| config_version | integer | 是 | 配置版本,必须与当前云端版本一致 |
| attempt_id | string | 是 | 设备生成的UUID v4,表示一次引擎初始化或首通执行 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `AttemptResponse.data`。
## C09 回报设备执行结果
`POST /api/v1/device/activation-results`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| activation_id | string | 是 | 该物理设备唯一的激活任务UUID |
| attempt_id | string | 是 | 设备生成的UUID v4,表示一次引擎初始化或首通执行 |
| stage | string | 是 | SDK_REGISTER或CONVERSATION_TEST;与C08平台当前阶段一致 |
| sdk_result | integer | 是 | SDK执行返回码;SUCCESS_OBSERVED时必须为0 |
| outcome | string | 是 | SUCCESS_OBSERVED、DEFINITE_FAILURE或UNKNOWN;未知结果不得盲目重试 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ResultResponse.data`。
## C10 激活结果保存回执
`POST /api/v1/device/activation-acks`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| activation_id | string | 是 | 该物理设备唯一的激活任务UUID |
| local_generation | integer | 是 | 本次启动的本地持久化序号,不能倒退 |
| config_version | integer | 是 | 配置版本,必须与当前云端版本一致 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ActivationAckResponse.data`。
## C11 关闭临时设置入口
`POST /api/v1/device/setup-close`
调用身份:设备DEVREQ1签名
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| setup_id | string | 是 | 云端15分钟临时设置会话UUID |
| reason | string | 是 | 关闭、轮换或人工处理原因,见对应枚举 |
| final_revision | integer | 是 | 设备关闭设置入口前看到的最新状态版本 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `CloseResponse.data`。
## 手机兑换一次性入口
`POST /api/v1/web/handoffs/exchange`
调用身份:同源网页;一次性token和verifier
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| token | string | 是 | 90秒内只能兑换一次的handoff令牌 |
| verifier | string | 是 | 设备交给手机的PKCE校验值;32字节随机数的Base64URL无填充字符串 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `BrowserResponse.data`。
## 恢复手机设置会话
`GET /api/v1/web/setup/session`
调用身份:设置Cookie + 写请求CSRF
无请求正文。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `BrowserResponse.data`。
## 关联OIDC登录
`POST /api/v1/web/setup/auth-link`
调用身份:设置Cookie + CSRF + OIDC Bearer
正文:`{}`。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `BrowserResponse.data`。
## 发送登录短信
`POST /api/v1/web/auth/sms/send`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| phone_e164 | string | 是 | 含国家区号的手机号,例如+8613800000000 |
| purpose | string | 是 | 固定LOGIN |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `SMSResponse.data`。
## 验证短信并登录
`POST /api/v1/web/auth/sms/verify`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| sms_challenge_id | string | 是 | 发送短信得到的挑战UUID |
| code | string | 是 | 6位短信验证码;5分钟有效,最多验证5次 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `BrowserResponse.data`。
## 查询可选择的家庭使用者
`POST /api/v1/web/profiles`
调用身份:设置Cookie + 写请求CSRF
正文:`{}`。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ProfilesResponse.data`。
## 创建家庭或使用者
`POST /api/v1/web/profiles/create`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| household_id | string | 条件或可选 | 家庭公开UUID;省略时须提供新家庭名称 |
| household_name | string | 条件或可选 | 新建家庭的名称 |
| preferred_name | string | 是 | 使用者称呼,用于设备人设和家庭展示 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ProfileResponse.data`。
## 提交绑定申请
`POST /api/v1/web/claims`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| elder_id | string | 是 | 使用者公开UUID,复用subject_profile |
| expected_binding_generation | integer | 是 | 提交时预期绑定代数,防止跨解绑或重新绑定重放 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ClaimResponse.data`。
## 取消自己的绑定申请
`POST /api/v1/web/claims/cancel`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| claim_id | string | 是 | 待物理确认的绑定申请UUID |
| expected_binding_generation | integer | 是 | 提交时预期绑定代数,防止跨解绑或重新绑定重放 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `StateOnlyResponse.data`。
## 查询手机绑定进度
`POST /api/v1/web/setup/status`
调用身份:设置Cookie + 写请求CSRF
正文:`{}`。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `WebStatusResponse.data`。
## 网页确认收到绑定结果
`POST /api/v1/web/setup/ack`
调用身份:设置Cookie + 写请求CSRF
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| binding_id | string | 是 | 当前已生效的绑定公开UUID |
| binding_generation | integer | 是 | 绑定代数;解绑、重新绑定后旧代数无效 |
| received_revision | integer | 是 | 网页已收到的设备状态版本 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `AckResponse.data`。
## 设置结束后查询我的设备
`GET /api/v1/web/onboarding/devices`
调用身份:账号Cookie;设备列表也支持OIDC Bearer
无请求正文。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `DevicesResponse.data`。
## 恢复账号会话及CSRF
`GET /api/v1/web/account/session`
调用身份:账号Cookie;设备列表也支持OIDC Bearer
无请求正文。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `AccountResponse.data`。
## 退出本浏览器登录
`POST /api/v1/web/auth/logout`
调用身份:账号Cookie;设备列表也支持OIDC Bearer
正文:`{}`。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `LogoutResponse.data`。
## 查询生产设备和激活状态
`GET /api/v1/ops/device-inventory`
调用身份:运维OIDC Bearer + X-Tenant-ID
无请求正文。
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `DevicesResponse.data`。
## 登记生产设备
`POST /api/v1/ops/device-inventory`
调用身份:运维OIDC Bearer + X-Tenant-ID
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| factory_mac | string | 是 | 12位大写十六进制MAC;相同MAC不能创建第二台物理设备 |
| hardware_model | string | 是 | 生产登记型号,当前为KP01 |
| hardware_revision | string | 是 | 硬件修订号,注册必须与库存一致 |
| batch_no | string | 是 | 生产批次,方便追溯 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `InventoryResponse.data`。
## 向工装交付加密密钥
`POST /api/v1/ops/device-inventory/{id}/credential-deliveries`
调用身份:运维OIDC Bearer + X-Tenant-ID
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| enrollment_job_id | string | 是 | 生产登记或密钥轮换任务UUID |
| receiver_public_key_pem | string | 是 | 工装RSA公钥,PKIX PEM,2048至4096位,指数65537 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `DeliveryResponse.data`。
## 确认烧录读回并放行
`POST /api/v1/ops/device-inventory/{id}/release`
调用身份:运维OIDC Bearer + X-Tenant-ID
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| enrollment_job_id | string | 是 | 生产登记或密钥轮换任务UUID |
| evidence_ref | string | 是 | 烧录读回或人工对账的证据引用,不放密钥 |
| expected_key_version | integer | 是 | 交付放行目标版本;轮换时为当前生效版本 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `ReleaseResponse.data`。
## 准备轮换凭据
`POST /api/v1/ops/device-inventory/{id}/credential-rotations`
调用身份:运维OIDC Bearer + X-Tenant-ID
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| reason | string | 是 | 关闭、轮换或人工处理原因,见对应枚举 |
| expected_key_version | integer | 是 | 交付放行目标版本;轮换时为当前生效版本 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `InventoryResponse.data`。
## 人工记录激活对账结论
`POST /api/v1/ops/onboarding-activations/{id}/reconcile`
调用身份:运维OIDC Bearer + X-Tenant-ID
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
| expected_version | integer | 是 | 运维列表中的激活任务版本,防止覆盖并发处理 |
| conclusion | string | 是 | OBSERVED_REGISTERED、CONFIRMED_NOT_REGISTERED或DEVICE_ATTEMPT_STOPPED |
| evidence_ref | string | 是 | 烧录读回或人工对账的证据引用,不放密钥 |
| reason | string | 是 | 关闭、轮换或人工处理原因,见对应枚举 |
| attempt_id | string | 条件或可选 | 设备生成的UUID v4,表示一次引擎初始化或首通执行 |
| device_secret | string | 条件或可选 | 已核实的供应商单设备密钥,仅人工对账写入,加密保存 |
| rtc_app_id | string | 条件或可选 | 供应商RTC应用ID;不填则使用已保存值 |
响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `StateOnlyResponse.data`。
@@ -0,0 +1,9 @@
# 首次接入实现记录
2026-09-11:本轮本地代码实施与验证已完成。正式说明见 [实施与联调说明](onboarding-implementation.md),全部接口字段见 [字段清单](onboarding-api-fields.md)。
已交付:53–57迁移、31项HTTP操作、设备独立认证、生产交付/放行/轮换、手机登录与家庭资料、按键确认绑定、双端ACK、激活outbox与补偿、配置回执与首通验证、代数及重放保护、手机页与运维页、OpenAPI及生成类型、调用工具、测试向量、灰度和恢复说明。
验证:全量Go单元测试、go vet、10个模块独立PostgreSQL/Redis集成测试、OpenAPI本地引用检查、前端TypeScript/生产构建、12个浏览器截图检查点通过。前端构建仍提示现有主bundle较大,构建成功;未在本轮重构整个前端拆包。
未发布生产。只在本地新建隔离数据库演练迁移,实际.env和已有业务库未改。真实短信、供应商账户、目标SDK、物理按键及NVS持久化由后续联调验收,不以合成测试结果代替。
+63
View File
@@ -0,0 +1,63 @@
# 首次认证、绑定与激活:实施说明
更新:2026-09-12。设备契约 ONBOARDING-1.1,按用户要求移除 recovery_action、retry_after_ms、next_action。详细输入、输出和示例见[设备端接口](设备端注册绑定接口.md);完整操作见[字段清单](onboarding-api-fields.md)及[OpenAPI](../openapi/onboarding.yaml)。发布结果见[发布记录](onboarding-release-20260912.md)。
## 已实现功能
- 生产库存登记、MAC 唯一性、单设备独立密钥、工装加密交付、放行及凭据轮换。
- 专用 HTTP 校时、TIME1 响应签名、HTTPS 启动注册、DEVREQ1 请求签名和 Redis nonce 防重放。
- 临时设置会话、PKCE 一次性交接入口、OIDC 登录、家庭与使用者选择、手机申请及设备物理按键确认。
- 绑定事务、绑定代数、权限校验、持久化回执、双端 ACK 收尾、重启和断网恢复。
- 激活配置、设备 SDK 执行许可、结果上报、结果保存确认、首次通话和人工对账。
- 旧设备认证保留;解绑、隐私删除和凭据轮换使旧归属请求失效。
手机入口为 `/onboarding/entry#token=<handoff_token>&verifier=<verifier>`,由设备本地页面打开。手机临时流程为 `/onboarding/setup`,已绑定设备为 `/onboarding/devices`;生产登记和对账为 `/maintenance?area=onboarding`。
## 设备认证
C01 请求包含 device_id、key_version、boot_id、client_nonce,无请求时间戳或请求 HMAC。成功根对象直接返回 payload_b64、mac_hex。设备核验 `HMAC-SHA256(raw_device_key, "TIME1\n" + payload_b64)`,解码并核对本设备、本次启动及新随机数,再设置时间。包有效期120秒;固件还应执行原附件中的单调时钟往返限时、可信时间下限检查。
C02–C11 使用严格验证服务器证书的 HTTPS。DEVREQ1 九行使用 LF 分隔,末尾无换行:版本、方法、路径、设备编号、密钥版本、UTC毫秒、nonce、请求UUID、原始正文SHA256。完整签名规则以设备文档为准。
请求正文最大8KiB,拒绝多份JSON、重复字段、未知字段、缺少字段、null必填字段和非法UTF-8。设备请求不带query。版本及计数为0至2147483647,SDK返回码允许有符号32位整数。
业务重试保持请求UUID及原始正文不变,重新生成时间戳、nonce及签名。响应缓存同时检查当前启动、设备密钥、绑定代数、权限和原业务对象。C01不使用普通业务响应封装;其他成功外层为 `{ok,code,request_id,server_time_ms,data}`,失败为 `{ok,code,request_id,message}`。
## 绑定与收尾
手机申请不直接建立归属,C06真实按键确认后才提交绑定与激活任务。绑定变更提升同一条单调递增代数,旧请求不能恢复旧归属。
C07保存设备已接收状态。仅设备ACK时至少等待30秒;网页也ACK时从双方均完成起至少等待3秒。C07重复请求重新计算setup_can_close,但不延长原accepted_at。C11最终检查最新版本及关闭条件。
## 激活状态机
1. 绑定后,worker或C05准备加密配置;worker不会调用供应商注册。配置包含附件E.2字段,iot内是instance_id、product_key、product_secret、device_name。
2. C05激活命令仅返回config_b64、config_mac。设备验证CFG1 HMAC及包内设备、绑定代数、版本、到期时间。
3. 设备申请C08许可,平台在数据库持久化执行编号及30秒开始执行期限。未注册时对应SDK_REGISTER;已经注册并C10确认保存后对应CONVERSATION_TEST,不新增输入字段。
4. C09报告SUCCESS_OBSERVED、DEFINITE_FAILURE或UNKNOWN,阶段须匹配执行编号。UNKNOWN和到期未报结果均阻止新的不确定执行;仅许可过期不能当作失败。
5. 注册成功是物理设备事实,保留至解绑以后。C10确认结果保存,随后首通成功使当前绑定进入READY。重新绑定必须重新进行首通,不能使用上一代READY证明。
6. 配置有效期24小时,更新版本前确认没有不确定执行。相同绑定下续期配置ACK后可复用已记录的首通证明。
默认首通由设备SDK直接执行,C09携带设备认证后的观察结果。若首通走既有平台v3通话接口,则仍传原通话必填字段及intent=activation_test、activation_id、attempt_id,不需要permit_token;平台按关联会话核验开始、首音频、结束事件。每个执行编号最多关联一次会话。
C09不上传DeviceSecret。设备SDK生成的单设备凭据应按原文在设备本地保存。平台既有代管通话如需该密钥,仍使用原有预配或运维核验凭据机制;本次没有从精简接口推导新的密钥上传字段。
## 人工对账
需要ops_admin,填写任务版本、证据引用、处理原因。供应商注册结果不明时,核实注册成功用OBSERVED_REGISTERED;确认没有注册且已停止执行用CONFIRMED_NOT_REGISTERED。DeviceSecret是可选的既有平台凭据补录字段。DEVICE_ATTEMPT_STOPPED仅可释放已停止的首次通话尝试,不能借此跳过注册对账。许可到期后还需经过3分钟等待窗口,平台不会伪造供应商查询结果。
## 发布配置
数据库迁移000053至000058增加首次接入所需身份、会话、激活和回执记录;000052是原有设备重置计数迁移。
服务器使用原有compose.yaml、compose.override.yaml及compose.subtitle.yaml,再叠加[compose.onboarding.yaml](../deploy/server/compose.onboarding.yaml)。只更新API、worker、web及网关;原凭据和已有业务配置保留。ONBOARDING_MASTER_KEY_FILE指向服务器单独生成的64位hex主密钥文件。API和worker必须读取同一份主密钥和供应商产品配置。
ONBOARDING_ENABLED、ONBOARDING_ACTIVATION_ENABLED控制新流程;ONBOARDING_PAUSE_CLAIMS暂停新绑定申请。ONBOARDING_TRUSTED_PROXY_CIDRS应为实际网关Docker网段;ONBOARDING_ALLOW_LOCAL_HTTP在线上保持false。Nginx仅放行HTTP的C01,其余HTTP仍跳转HTTPS。
已有OIDC账号可以绑定。短信仅在配置真实发送通道后开启。自动创建账号的租户白名单默认空,按实际运营范围配置ONBOARDING_SELF_SIGNUP_TENANT_IDS。
## 验证
`go test ./...`、`go vet ./...`、`scripts/test-onboarding-integration.ps1`、`npm run generate:api`和`npm run build`均已通过。集成测试为各模块创建独立迁移数据库,不改本地业务数据库。覆盖设备11接口、按键绑定、跨代重放、未知结果、30秒许可、保存顺序、配置续期、直接SDK和既有SaaS会话路径。
测试中的SDK观察结果和短信是合成数据;实物按键、断电持久化及真实供应商SDK尚需设备端联调。
+45
View File
@@ -0,0 +1,45 @@
# 设备认证与绑定接口发布记录
发布时间:2026-09-12 01:01:44(北京时间)。
已部署版本:`0.81.0-rc.onboarding1`。设备协议:`ONBOARDING-1.1`,按本次要求删除辅助流程字段。当前发布目录为 `/opt/data-terminal-cloud/releases/0.81.0-rc.onboarding1`,`/opt/data-terminal-cloud/current` 已切换到该目录。
## 在线地址
- 平台:[https://121.41.46.238](https://121.41.46.238)
- 运维首次接入:[登记与放行](https://121.41.46.238/maintenance?area=onboarding)
- 手机绑定:[设置页面](https://121.41.46.238/onboarding/setup),须先从设备生成的一次性交接入口进入;单独打开不能替代设备授权。
- 已绑定设备:[我的设备](https://121.41.46.238/onboarding/devices)
- 业务接口根地址:`https://121.41.46.238/api/v1/device/`
- 专用校时:`POST http://121.41.46.238/api/v1/device/time-challenges`
完整字段、中文解释、数据来源与示例见[设备端接口文档](设备端注册绑定接口.md)。
## 本次实现及部署
设备 C01–C11 输入输出已对齐。校时成功直接返回签名包;平台按任务状态发放30秒SDK执行许可;注册与首通分别上报;C10确认结果保存;未知结果及执行中断电重启进入待对账,禁止重复注册。SaaS worker负责准备配置,不再替这些新设备调用供应商注册。
更新API、worker、前端和Nginx。保留服务器现有供应商、字幕、记忆和登录配置,以及旧设备协议。认证主密钥只在服务器凭据目录保存,权限为root所有、运行用户组65532可读(0640),没有放入源码、镜像或项目压缩包。
数据库从51升至58,迁移状态正常。发布前已备份PostgreSQL、Redis、对象存储和登录服务数据,加密备份:`/var/backups/data-terminal-cloud/platform-20260911T165021Z.tar.gz.enc`。原发布目录 `0.80.2-rc.profile-ui3` 保留。
## 验证结果
- 全量Go测试和go vet通过;10个模块的隔离PostgreSQL/Redis集成测试通过。
- 最后补充的断电许可保护已重新运行设备接口集成测试及相关API、HTTP、通话模块测试。
- 前端TypeScript和生产构建通过。
- 线上20项检查通过,使用正常证书校验,无关闭TLS验证。
- API和worker健康检查通过;在线版本接口返回本发布版本。
- 11条设备路由的未授权或缺字段响应符合契约,没有已删除的辅助字段。
- HTTP校时接口返回平台JSON错误而非HTTPS重定向;其他HTTP业务与手机入口仍跳转HTTPS。
- 手机绑定、我的设备、运维入口和对应新版前端静态资源可访问。
设备完整认证、按键绑定、配置签名及激活成功路径在隔离数据库中验证。线上探测未使用真实设备密钥、未创建测试设备或业务账号。真实板卡、SDK注册、物理按键和断电持久化尚需设备端联调。短信发送通道未配置,本次保留OIDC账号登录,短信开关关闭。
## 后续维护
启动命令需包含原有三个Compose文件,并最后叠加 `deploy/server/compose.onboarding.yaml`;该文件固定本次API、worker和前端镜像。不要只运行旧的两文件部署命令,否则会遗漏首次接入运行配置。
暂停新业务优先关闭ONBOARDING_ENABLED、ONBOARDING_ACTIVATION_ENABLED或开启ONBOARDING_PAUSE_CLAIMS,保留认证主密钥与历史数据。已有新协议设备投入使用后,不能直接回滚为不识别新设备的旧应用;应保留数据库迁移并采取前向修复。
源码基线:`7846f30d0b239e183267988aa6e030b0a2f1ef18`,本次包含工作区实现,以项目包清单及文件哈希为准。未新建Git提交。
+36
View File
@@ -0,0 +1,36 @@
# Viking managed memory integration
Configured collection: `memory`, resource `mem-3a1174a1`, project `default`, Beijing endpoint. The existing collection has emotional-companionship extraction rules `event_v1` and `profile_v1` (assistant isolation enabled). This is the managed Viking memory service, not OpenViking.
## Runtime
API and worker accept `VIKING_MEMORY_RESOURCE_ID`, `VIKING_MEMORY_COLLECTION_NAME`, `VIKING_MEMORY_API_KEY_FILE`, `VIKING_MEMORY_EVENT_TYPE` and `VIKING_MEMORY_PROFILE_TYPE`. An empty resource ID disables the integration. An empty collection name keeps the compatibility startup-prefetch path. The API key is a server-only file; it is never returned to a device or written into a firmware image. The endpoint is HTTPS; redirects are disabled. Error logs contain status/code, never vendor response bodies or transcripts.
Terminal conversation extraction requests also enqueue an independent `conversation.viking_sync_requested` outbox event. The worker submits final user and assistant turns with AddSession. Frozen transcript/memory consent and current subject/memory/transcript eligibility are checked. No user-only or diagnostic assistant-only conversation is submitted. The database remains the source of full transcripts.
User identity is SHA-256 of the tenant and subject UUIDs, prefixed `u_`; it survives device restarts and changes of device binding. Assistant identity is the stable platform companion ID, currently shared across platform persona versions. Every conversation has a distinct `c_<conversation UUID without hyphens>_<batch>` session identifier. Batches contain up to 100 messages. A retry uses the same identifier; a late transcript revision replaces that conversation's batch. Audit digests avoid rewriting unchanged successful submissions. Initial indexing/extraction is asynchronous.
At hardware session creation the API resolves the currently bound subject and revalidates memory consent. It returns a per-user, non-secret native memory description containing the collection name, hashed user ID, stable assistant ID, extraction-rule names, limit and transition words. Firmware maps this description to Volcano `Config.MemoryConfig`; Volcano therefore retrieves topic-relevant memories for every turn. A different subject binding produces a different hashed user ID. The Viking API key stays on the platform worker and is used only for writes, platform search and erasure.
The dynamic device update also enables `S2SConfig` while retaining `LLMConfig` for hybrid orchestration. S2S App ID and token live in the Volcano bot configuration and in root-owned server recovery files, never in device session responses or firmware. The device supplies only per-session dialog/persona fields. Existing in-call memory search remains as a compatibility path for deployments without a configured native collection.
There are no database migrations. Native subtitle callbacks remain in device-probe mode; the reliable device transcript upload supplies these sessions. Pending memory extraction is independent of streamed audio playback.
## Lifecycle and limits
Current consent withdrawal immediately gates future Viking reads/writes. Subject privacy erasure calls scoped event/profile deletion before completing the provider-index deletion subtask. Vendor extraction is asynchronous; late vendor extraction after an erasure request needs follow-up reconciliation before treating provider erasure as fully verified. Individual locally curated-memory deletion does not delete independently extracted Viking events/profiles. For the present integration, use the Viking console for editing or deleting those derived entries; a unified platform memory-management UI is not implemented. Do not describe the existing local memory list as a complete Viking list.
Automatic extraction begins on conversation completion/failure and final transcript delivery. A sudden power-off relies on the existing heartbeat recovery to close the conversation. Native retrieval is available again after every restart because identity comes from the platform binding, not device RAM. Full historical backfill is explicit; enabling the integration does not automatically export all past conversations.
## Verification
Deployment on 2026-09-06: API and worker `0.79.0-rc.s2s-memory1`; callbacks remain `0.78.3-rc.subtitle-memory3`. Readiness returned 200. Current symlink points to `/opt/data-terminal-cloud/releases/0.79.0-rc.s2s-memory1`; rollback uses `0.78.4-rc.viking1`. The Volcano bot uses S2S hybrid mode, and `VoiceChatRoleForRTC` has both required Viking policies. The pre-update bot configuration is stored in the protected first-version backup.
One real conversation (`157f866d-c364-4ae8-b169-50ca07024e5a`, three final messages) was explicitly queued for backfill. Viking produced two event memories, and the production service retrieved 76 characters of startup memory. Synthetic event/profile entries were deleted and subsequent queries returned zero. Device power-cycle/spoken recall is pending user verification; server retrieval alone is not proof of successful audible recall.
- Real API-key calls verified against the configured collection: AddSession, event search, profile search.
- Synthetic test used a separate user namespace; no test preference is attached to the real user.
- Tests cover vendor result scope filtering, error redaction, stable identity/session IDs, native per-user filter mapping, scoped deletion, SQL outbox deduplication, late transcript replacement, frozen consent, cross-tenant rejection, and subject-bound startup context.
- A dedicated PostgreSQL/Redis environment is used for integration tests, separate from production data.
Official API references: https://www.volcengine.com/docs/84313/1783353 , https://www.volcengine.com/docs/84313/2100967 , https://www.volcengine.com/docs/84313/2100966 , https://www.volcengine.com/docs/84313/1783358 , https://www.volcengine.com/docs/84313/1783359 .
@@ -0,0 +1,230 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "火山硬件对话智能体迁移后目标架构",
"locale": "zh-CN",
"quality_profile": "showcase",
"viewBox": [1360, 820]
},
"components": [
{
"id": "elder",
"type": "external",
"label": "长者用户",
"sublabel": "说话、打断、挂机",
"pos": [40, 230],
"size": [130, 72]
},
{
"id": "device",
"type": "frontend",
"label": "陪伴硬件",
"sublabel": "麦克风、扬声器、本地队列",
"pos": [230, 230],
"size": [180, 72]
},
{
"id": "hardwareSdk",
"type": "frontend",
"label": "火山硬件智能体 SDK",
"sublabel": "高质量 UDP/RTC 主链",
"pos": [490, 230],
"size": [200, 72]
},
{
"id": "volcAgent",
"type": "external",
"label": "火山硬件对话智能体",
"sublabel": "Bot、ASR/LLM/TTS、会话",
"pos": [770, 230],
"size": [220, 72]
},
{
"id": "deviceRegistry",
"type": "external",
"label": "火山产品与设备管理",
"sublabel": "Product、Device、License、Bot 绑定",
"pos": [770, 50],
"size": [220, 72]
},
{
"id": "platformApi",
"type": "backend",
"label": "平台会话控制面",
"sublabel": "授权、冻结快照、心跳、停止",
"pos": [490, 470],
"size": [200, 76]
},
{
"id": "toolGateway",
"type": "security",
"label": "工具与记忆网关",
"sublabel": "MCP/Function Calling、短期会话令牌",
"pos": [1070, 230],
"size": [220, 76]
},
{
"id": "dataStore",
"type": "database",
"label": "业务主数据",
"sublabel": "PostgreSQL、pgvector、Redis",
"pos": [770, 470],
"size": [220, 76]
},
{
"id": "asyncPipeline",
"type": "backend",
"label": "异步内容与运营流水线",
"sublabel": "字幕、摘要、故事、费用、审计",
"pos": [1070, 470],
"size": [220, 76]
},
{
"id": "objectStorage",
"type": "database",
"label": "私有对象存储",
"sublabel": "可选双轨录音与导出",
"pos": [230, 650],
"size": [180, 72]
},
{
"id": "webApps",
"type": "frontend",
"label": "家庭端与运营端",
"sublabel": "设备、通话、隐私、配置、诊断",
"pos": [490, 650],
"size": [200, 72]
}
],
"boundaries": [
{
"kind": "region",
"label": "设备侧",
"wraps": ["device", "hardwareSdk"],
"pad": 22
},
{
"kind": "region",
"label": "火山引擎托管域",
"wraps": ["volcAgent", "deviceRegistry"],
"pad": 22
},
{
"kind": "security-group",
"label": "自有平台可信域(业务事实唯一主库)",
"wraps": ["platformApi", "toolGateway", "dataStore", "asyncPipeline", "objectStorage", "webApps"],
"pad": 24
}
],
"connections": [
{
"id": "humanVoice",
"from": "elder",
"to": "device",
"label": "双向语音",
"variant": "emphasis"
},
{
"id": "audioFrames",
"from": "device",
"to": "hardwareSdk",
"label": "PCM/设备事件",
"variant": "emphasis"
},
{
"id": "agentLink",
"from": "hardwareSdk",
"to": "volcAgent",
"label": "SDK 直连会话",
"variant": "emphasis",
"labelAt": [730, 316]
},
{
"id": "provisioning",
"from": "deviceRegistry",
"to": "hardwareSdk",
"label": "设备凭证与 Bot 绑定",
"variant": "security"
},
{
"id": "prepareSession",
"from": "device",
"to": "platformApi",
"label": "HTTPS 会话控制、字幕与状态",
"variant": "security"
},
{
"id": "toolCalls",
"from": "volcAgent",
"to": "toolGateway",
"label": "MCP / FC",
"variant": "security"
},
{
"id": "toolData",
"from": "toolGateway",
"to": "dataStore",
"label": "租户与长者范围查询",
"variant": "security"
},
{
"id": "conversationFacts",
"from": "platformApi",
"to": "dataStore",
"label": "会话/授权快照",
"variant": "emphasis"
},
{
"id": "asyncJobs",
"from": "dataStore",
"to": "asyncPipeline",
"label": "Outbox 可靠任务",
"variant": "dashed",
"labelAt": [1030, 560]
},
{
"id": "mediaUpload",
"from": "device",
"to": "objectStorage",
"label": "可选加密录音直传",
"variant": "dashed",
"fromSide": "bottom",
"toSide": "top",
"labelAt": [320, 316]
},
{
"id": "webControl",
"from": "webApps",
"to": "platformApi",
"label": "HTTPS/OIDC",
"variant": "security"
}
],
"cards": [
{
"dot": "emerald",
"title": "保留不动",
"items": [
"平台继续掌握设备、授权、通话、记忆、费用和审计主数据",
"字幕后的摘要、故事、人工确认记忆和隐私删除链路继续复用"
]
},
{
"dot": "amber",
"title": "替换范围",
"items": [
"设备不再领取平台 RTC Token,也不再等待云端 StartVoiceChat",
"火山硬件 SDK 直接建立会话,旧 RTC 房间与 Agent Task 仅保留兼容期"
]
},
{
"dot": "rose",
"title": "上线硬门槛",
"items": [
"必须验证服务端强制停话、设备凭证吊销、字幕/用量回传和近远端录音能力",
"任何一项无法闭环时,先限定为试点能力,不替换现网主链"
]
}
]
}
+61
View File
@@ -0,0 +1,61 @@
# 火山字幕回调与重启记忆修复(2026-09-06)
## 设备实测更新(北京时间 10:20)
- **已烧录并启动**:COM6,ESP32-S3 MAC b4:3a:45:16:34:a8。最终固件 app `0x2ccf40`,日志 `build/native-subtitle-stable-{build,flash,device}.log`,烧录哈希校验成功。NVS/Wi-Fi/绑定均保留;设备已联网待机。
- 实际用户测试会话 `157f866d-c364-4ae8-b169-50ca07024e5a`:设备保存 3 条文字(其中 1 条为前置播放测试),双方实际对话保存成功,状态 COMPLETED,2 条 READY 摘要。设备约每 15 秒续期成功。之后创建的新会话已携带非空历史上下文;实际断电后口头回忆仍需用户反馈。
- **真实火山回调已收到,但不能据此宣称按会话入库完成。** 当前 low_load 回调只有 message/signature;解码后包含 productKey、deviceName、type、data,说话者 userId 为 input/output,没有 job_id、session_id 或 conversation_id。
- 平台确实下发了带 conversation_id 的 URL 和 43 字符会话签名。固件 session.update 使用官方 AgentConfig.ServerMessageURLForRTS/ServerMessageSignatureForRTS 字段,但实测仍回调控制台静态 URL 和静态签名。额外测试 ServerMessageUrl/ServerMessageSignature 别名也未改变结果,该实验已从最终固件移除。
- 不允许通过“设备最新会话”归属回调,否则旧会话延迟字幕可能写入重启后的新会话。**保持 VOLC_SUBTITLE_PRIMARY=false。设备 turns:batch 仍为唯一正式记录来源。**
- callbacks 服务已更新为 `0.78.3-rc.subtitle-memory3`(api/worker 仍为 memory1),静态回调校验签名、设备身份及格式后返回 200,仅记无正文的验证日志,不存第二份文字。支持 Base64 JSON 和 subv 二进制封装。错误签名返回 401。公网验证前后 conversation_turn 行数不变,服务 ready。
- 两个控制台配置值不变。其静态配置已能将字幕发送到平台,**尚未完成独立的、可靠的会话归档**。独立入库前必须解决会话身份传递,并按实测 input/output 更新和验证说话者映射;不能直接开启 primary。
- 下一步供应商核对事项:低负载 session.update 是否支持动态回调 URL/签名?支持时需要的准确字段/生效时机是什么?不支持时能否在字幕中返回与 session.created.job_id 相同的编号?必要时评估平台管理每设备独立智能体及启动前冻结配置,但尚未创建、修改或批量克隆任何火山智能体。
以下为上一轮部署时的设计和验证记录;与本节冲突的“未烧录/未实测”描述已由本节更新取代。
## 已上线
- 后端:`0.78.3-rc.subtitle-memory1`,服务器 `/opt/data-terminal-cloud/current` 指向同名 release。
- `POST /callbacks/v1/volcengine/subtitles` 支持没有 Content-Type 的火山原生请求:外层 message/signature,message 解码后为 `subv` + 四字节大端长度 + subtitle JSON。
- 当前 `VOLC_SUBTITLE_PRIMARY=false`:回调仅验证,不存第二份文字;设备原有 turns:batch 仍是正式记录来源。
- 有效摘要现在包含已失败的会话,仍限定租户、用户、授权及 READY 状态。硬件结束、失败、超时按实际最大 turn sequence 安排摘要提取。
- 无数据库迁移;未发布工作区其他改动。上一个 release 可用于回退。
## 火山控制台填写
- ServerMessageUrl:`https://121.41.46.238/callbacks/v1/volcengine/subtitles`
- ServerMessageSignature:使用本机被 Git 忽略的 `.deploy-artifacts/volc-subtitle-console-settings.txt` 中同名值。此文档不复制密钥。
这两项是基础配置,不能单靠基础 URL 推断归属会话。新固件上报 `native_subtitle_callback=true` 后,平台每次创建会话返回 `session_config.subtitle_callback_url`(带 conversation_id)和 `subtitle_callback_signature`(绑定该会话的 HMAC 值)。固件在 session.update 的 agent_config 中设置 `ServerMessageURLForRTS` 和 `ServerMessageSignatureForRTS`,同时设置 config.SubtitleConfig.SubtitleMode=1。火山按普通自定义 signature 原样回传它,平台进行比较。
已冻结的会话各自保留记录来源和说话者映射,不能用“设备当前最新会话”归档延迟回调。完成的分句全部保留,不能只保留 paragraph=true;稳定键按 userId、roundId、sequence 去重。没有供应商时间戳的回调只记录接收时间,不声称等于实际说话时间。网络乱序的分句按入库顺序分配平台序号。
## 尚未完成的设备验证
- COM6 尚未出现,COM3 的 VID:PID 为 1D6B:011E,是另一台设备;COM4/5 为蓝牙。**没有烧录新固件**。
- 最新编译:`firmware/echoear-platform-agent/build/native-subtitle-build.log`,app 大小 `0x2ccf40`;保留此前连续播放和眼睛动画修复,新增心跳续期及字幕配置。
- 不能把平台发出的合成 HTTP 检查当成真实火山回调。当前 low_load 模式是否发送原生服务端字幕还需用设备验证;服务端状态回调仍关闭。
- 先烧录 app 分区,保留 NVS/Wi-Fi/绑定。确认真实用户和助手字幕均能到达,并核对实际 userId 与冻结映射。未知或有歧义的说话者禁止入库。未验证前不得启用 primary。
- primary 只作用于新会话;固件收到 subtitle_source=provider 后停止设备文字上传,平台也拒绝该会话的设备补传,防止双份记录。切换后还需验证重连、断电回忆、分句完整性及播放连续性。
## 验证结果
- 单元测试:协议截断/错误长度、缺字段、错误签名、跨会话签名、无 Content-Type、说话者歧义等通过。
- 独立临时 PostgreSQL(不是生产数据库):完成/失败/活跃会话的摘要选择、隔离其他用户、末尾提取版本与去重、字幕 probe 不入库、primary 入库与重复处理、未知说话者、冲突、撤回授权测试通过。日志 `.deploy-artifacts/subtitle-db-test.log`。
- 公网 HTTPS(正常证书校验)协议检查:错误签名 401,正确签名 probe 200;原会话记录数测试前后均为 5,无测试聊天写入。
- API、callbacks、worker 的 `/health/ready` 均 ready,线上版本接口返回新版本。
## 运维命令
当前上线额外使用 `deploy/server/compose.subtitle.yaml` 固定三个后端服务镜像和回调环境变量。后续重启这版必须保留该文件:
```sh
cd /opt/data-terminal-cloud/current
docker compose -p data-terminal-cloud --env-file deploy/server/.env \
-f compose.yaml -f deploy/server/compose.override.yaml \
-f deploy/server/compose.subtitle.yaml up -d --no-deps api callbacks worker
```
首次启动曾因 Windows 编译文件无 Linux 执行位被拒,已自动回退并修正为打包前 chmod 0755,随后成功上线。部署源码在本机 `.deploy-artifacts/subtitle-release`,包含明确文件清单;保留服务器基线的其他源码。
官方格式依据:[实时字幕(对话记录)](https://docs.volcengine.com/docs/6348/1337284),查阅于 2026-09-06。
+5 -1
View File
@@ -11,9 +11,12 @@
| 事件类型 | 产生条件 | 资源类型 | 状态示例 |
|---|---|---|---|
| `device.presence.changed` | 首次有效心跳使设备上线,或后台发现心跳超时使设备离线 | `device` | `online`、`offline` |
| `device.binding.reset` | 设备主动恢复出厂并解除当前用户绑定 | `device` | `CLAIMABLE` |
| `conversation.started` | 通话进入`ACTIVE` | `conversation` | `ACTIVE` |
| `conversation.completed` | 通话进入`COMPLETED` | `conversation` | `COMPLETED` |
| `conversation.failed` | 通话进入`FAILED` | `conversation` | `FAILED` |
上述通话事件由`conversation.status`统一触发,与底层运行时无关;`legacy_voicechat`和`volc_hardware_agent`都会产生同一套家庭侧SSE语义。硬件智能体的周期`session_updated`只作为平台租约和诊断证据,不逐次生成SSE事件。
| `story.created` | 新故事候选建立 | `story` | 故事当前状态 |
| `story.revised` | 故事版本增加 | `story` | 故事当前状态 |
| `media.upload.completed` | 媒体上传批次完成 | `upload_batch` | `COMPLETED` |
@@ -105,7 +108,8 @@ sequenceDiagram
3. 设备此前不存在在线记录或已离线时,写入一次`online`事件;连续心跳只延长最后出现时间。
4. Redis继续承担低延迟在线查询,PostgreSQL状态表承担状态转换和事件可靠性。
5. Worker每个心跳周期扫描一次;最后出现时间超过两倍会话租约的记录切换为`offline`并只写入一次事件。
6. 新的有效心跳可再次把离线记录切回在线并产生新的上线事件。
6. 设备恢复出厂时,平台在同一事务内把旧用户侧在线状态改为`offline`、发送`device.binding.reset`并解除绑定。
7. 新的有效心跳可再次把离线记录切回在线并产生新的上线事件;恢复出厂后的设备要先完成重新绑定。
## 7. 浏览器重连与去重
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Archify visual-check · 火山硬件对话智能体迁移架构.html</title>
<style>
*{box-sizing:border-box}body{margin:0;padding:24px;background:#e9eef5;color:#172033;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}header{max-width:1500px;margin:0 auto 18px}h1{margin:0 0 6px;font-size:20px}p{margin:0;color:#526176}.grid{max-width:1500px;margin:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;padding:10px;background:white;border:1px solid #c9d4e3;border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.08)}img{display:block;width:100%;height:auto;border:1px solid #e2e8f0}figcaption{padding:9px 4px 2px;color:#526176}@media(max-width:900px){.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<header><h1>Archify visual-check</h1><p>火山硬件对话智能体迁移架构.html · automated containment pass · visual review pending</p></header>
<main class="grid">
<figure>
<img src="火山硬件对话智能体迁移架构.visual-check.1440x900.light.png" alt="light 1440 by 900">
<figcaption><strong>LIGHT</strong> · 1440×900 · containment pass</figcaption>
</figure>
<figure>
<img src="火山硬件对话智能体迁移架构.visual-check.1440x900.dark.png" alt="dark 1440 by 900">
<figcaption><strong>DARK</strong> · 1440×900 · containment pass</figcaption>
</figure>
<figure>
<img src="火山硬件对话智能体迁移架构.visual-check.2048x1320.light.png" alt="light 2048 by 1320">
<figcaption><strong>LIGHT</strong> · 2048×1320 · containment pass</figcaption>
</figure>
<figure>
<img src="火山硬件对话智能体迁移架构.visual-check.2048x1320.dark.png" alt="dark 2048 by 1320">
<figcaption><strong>DARK</strong> · 2048×1320 · containment pass</figcaption>
</figure>
</main>
</body>
</html>
@@ -0,0 +1,547 @@
{
"schemaVersion": 1,
"ok": true,
"command": "visual-check",
"status": "pass",
"visualReview": "pending",
"artifact": {
"path": "E:\\Codex\\story\\platform\\docs\\火山硬件对话智能体迁移架构.html",
"sha256": "baefbdc9512b0614c236f59cc80d8a14b8fb9489a3a6281214d636ad9fb68d8c",
"bytes": 721387
},
"state": {
"detail": "read",
"motion": "still"
},
"chrome": {
"status": "available",
"executable": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
},
"diagnostics": [],
"containment": {
"status": "pass",
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1042,
"diagramWidth": 1012,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.697058823529412,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1082,
"diagramWidth": 1052,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.961764705882353,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1216,
"diagramWidth": 1186,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 7.848529411764706,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1619,
"diagramWidth": 1569,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"readability": {
"status": "pass",
"minimumProjectedNodeTextPx": 6,
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1042,
"diagramWidth": 1012,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.697058823529412,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1082,
"diagramWidth": 1052,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.961764705882353,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1216,
"diagramWidth": 1186,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 7.848529411764706,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1619,
"diagramWidth": 1569,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"viewerChrome": {
"status": "pass",
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1042,
"diagramWidth": 1012,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.697058823529412,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1082,
"diagramWidth": 1052,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.961764705882353,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1216,
"diagramWidth": 1186,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 7.848529411764706,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1619,
"diagramWidth": 1569,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"captures": {
"status": "pass",
"screenshots": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1042,
"diagramWidth": 1012,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.697058823529412,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light",
"file": "火山硬件对话智能体迁移架构.visual-check.1440x900.light.png"
},
{
"width": 1440,
"height": 900,
"theme": "dark",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1042,
"diagramWidth": 1012,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.697058823529412,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "dark",
"file": "火山硬件对话智能体迁移架构.visual-check.1440x900.dark.png"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1619,
"diagramWidth": 1569,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light",
"file": "火山硬件对话智能体迁移架构.visual-check.2048x1320.light.png"
},
{
"width": 2048,
"height": 1320,
"theme": "dark",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1619,
"diagramWidth": 1569,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "dark",
"file": "火山硬件对话智能体迁移架构.visual-check.2048x1320.dark.png"
}
],
"contactSheet": "火山硬件对话智能体迁移架构.visual-check.html"
},
"sidecars": {
"receipt": "火山硬件对话智能体迁移架构.visual-check.json",
"contactSheet": "火山硬件对话智能体迁移架构.visual-check.html"
}
}
@@ -0,0 +1,496 @@
# 火山硬件对话智能体迁移设计
> 状态:方案评审稿
> 适用基线:`v0.70.0-rc.1`
> 编制日期:2026-08-31
## 1. 结论
本次迁移建议采用“保留业务控制面,替换会话执行面”的方式,不对现有平台做推倒重建。
- 保留:设备身份与绑定、家庭和长者档案、授权、通话主记录、配置灰度、字幕存储、摘要/故事/事实候选、人工确认记忆、隐私导出删除、录音管理、费用额度、告警、诊断、固件和审计。
- 替换:设备领取 RTC Token、设备加入平台分配的 RTC 房间、平台 Worker 调用 `StartVoiceChat/StopVoiceChat`、Agent RTC 用户和 RTC Task 对账。
- 新增:火山产品/设备/Bot/License 映射、硬件智能体设备凭证生命周期、硬件会话适配器、动态会话配置、MCP/Function Calling 工具网关、硬件会话事件和用量归集。
- 兼容:旧 RTC VoiceChat 链路和新硬件智能体链路至少并行一个灰度周期,按设备能力位和灰度配置选择,不按固件版本猜测。
推荐终端优先采用火山硬件智能体“高质量方案”(UDP/RTC)。现有终端本身已有 RTC、弱网恢复和网络切换验收基础,和该方案的目标更一致。火山官方同时提供低负载 WebSocket 方案;只有在量产板的 RAM、CPU、功耗或网络实测不满足高质量 SDK 时,才切换到低负载方案。官方对比说明高质量方案弱网和实时互动更好,低负载方案资源消耗更低。[火山官方 SDK 集成指引](https://www.volcengine.com/docs/6348/1913817?lang=zh)
## 2. 当前链路与目标链路
### 2.1 当前链路
```text
设备 -> 平台创建通话 -> 平台签发 RTC Token -> 设备加入 RTC 房间
-> 设备确认入房 -> Worker 调用 StartVoiceChat -> 豆包 Agent 入房
-> 火山/设备回传字幕和状态 -> 平台存储与内容处理
-> 设备或平台发起停止 -> Worker 调用 StopVoiceChat -> 通话终态
```
当前关键实现:
- `/device/v2/conversations` 创建通话并返回 RTC Grant;
- `/activate` 在设备入房后可靠投递 `conversation.agent_start_requested`;
- `voiceprovider.VolcengineClient` 把平台稳定任务契约映射为 `StartVoiceChat/StopVoiceChat`;
- RTC 房间、设备 RTC 用户、Agent RTC 用户和供应商 Task 是诊断主关联键;
- 最终字幕进入 `conversation_turn`,继续触发摘要、故事、事实和记忆流水线。
### 2.2 目标链路
```text
设备 -> 平台准备硬件会话并冻结授权/配置/背景
-> 设备使用火山硬件智能体 SDK 直连 Bot
-> SDK 收发音频并提供字幕、状态、Function Calling 事件
-> 设备把最终字幕和状态回传平台
-> Bot 通过 MCP/Function Calling 访问平台受控记忆/业务工具
-> 用户、平台或授权撤回触发停止 -> 设备关闭 SDK 会话 -> 平台收敛终态
```
火山硬件智能体的标准协议使用 `product_key + device_name + device_secret` 建立设备身份,连接时指定与产品关联的 `bot`。设备首次可通过 `DynamicRegister` 获取设备独享密钥;WebSocket 标准协议支持字幕事件、端侧 Function Calling、主动播报和文本提问。[火山标准协议文档](https://www.volcengine.com/docs/6348/1904602?lang=zh)
## 3. 架构原则
### 3.1 平台数据库仍是业务事实唯一主库
火山侧负责实时对话执行,不成为家庭、长者、授权、记忆和费用策略的主数据源。平台必须保存每次会话实际使用的:
- 产品、设备、Bot 和 License 的脱敏引用;
- 平台路由版本、角色版本、声音版本、模型版本和提示词版本;
- 发送给火山的会话配置摘要及 SHA-256;
- 授权和背景资料冻结快照;
- 火山会话 ID、传输方案、状态时间线、字幕来源和用量来源。
### 3.2 设备必须先通过平台业务门禁,再连接智能体
设备拥有火山凭证不等于拥有业务授权。每次对话前仍必须先调用平台准备接口,由平台检查:
- 设备已登记、未隔离、未吊销并已绑定长者;
- 固件和硬件智能体 SDK 版本满足最低要求;
- `voice_chat` 授权有效;
- 客户组织、设备并发、时长和费用额度未超限;
- 角色、模型、音色和 Bot 路由已发布;
- 背景资料和记忆快照生成成功或按既定规则无记忆降级。
平台拒绝准备时,设备不得调用火山 SDK 建立对话。
### 3.3 不把长期记忆主库直接迁到火山
现有记忆包含人工确认、授权撤回、版本、来源、租户/长者隔离和隐私删除证明。迁移后继续使用平台 `pgvector` 和记忆工具:
- 会话启动时,把已授权的短背景摘要作为动态会话参数注入;
- 通话中通过平台 MCP/Function Calling 工具查询记忆;
- 工具令牌绑定 `conversation_id + provider_session_id + device_id + subject_id`,短期有效;
- 模型只能提交查询内容,不能自行指定租户或长者;
- 火山原生长期记忆只可作为可删除、可对账的派生缓存,不能成为主库。
火山硬件智能体产品能力包含 MCP、联网问答、长期记忆和设备管理,但这不改变本项目的业务数据权威边界。[火山硬件智能体产品页](https://www.volcengine.com/product/ConversationalAI-Embedded)
## 4. 功能迁移矩阵
| 现有功能 | 迁移方式 | 新责任边界 | 结论 |
|---|---|---|---|
| 设备 HMAC 身份、绑定、家庭归属 | 原样保留;新增火山设备映射 | 平台仍负责业务身份,火山凭证只负责智能体建连 | 保留并扩展 |
| `/bootstrap`、配置、命令、遥测 | 原样保留,增加硬件智能体能力位和 SDK 信息 | 平台 | 保留 |
| RTC 房间分配与 Token | 新运行时不再使用 | 火山硬件 SDK 自行建连 | 新链路废弃 |
| `StartVoiceChat/StopVoiceChat` | 新运行时不再调用;旧链路继续兼容 | 设备 SDK / 火山硬件智能体 | 新链路废弃 |
| `s2s_pure/s2s_hybrid/cascade` | 不再作为设备会话模式;改为平台内部服务档位或 Bot/模型配置 | 平台路由 + 火山 Bot | 重定义 |
| 角色、提示词、模型、音色灰度 | 平台继续版本化;基础配置同步 Bot,会话差异通过动态参数注入 | 平台为权威,火山为执行副本 | 保留并改造 |
| 欢迎语、主动播报 | 使用 Bot/会话配置;本地系统提示可用 `input_tts` | 火山 SDK + 设备应用 | 迁移 |
| 背景资料冻结 | 准备会话时生成;等待 `session.updated` 后才开放麦克风 | 平台生成,设备注入 | 保留并改造 |
| 实时打断、VAD、ASR、TTS | 使用火山硬件智能体原生能力 | 火山 | 下沉 |
| 最终字幕 | 设备监听最终事件,写入本地加密队列,批量调用现有 `turns:batch` | 设备可靠上传,平台去重存储 | 保留并改造 |
| 字幕服务端回调 | 若火山提供则作为第二来源,按供应商事件 ID 去重 | 火山回调 + 平台 | 待商务/技术确认 |
| 摘要、开放话题、故事、事实候选 | 输入仍为平台最终话轮 | 平台 Worker | 原样保留 |
| 人工确认记忆、向量检索 | 继续使用现有数据库和流程 | 平台 | 原样保留 |
| 通话中记忆工具 | 从 VoiceChat 模板 URL 改为 MCP/Function Calling 工具网关 | 平台工具网关 | 改造 |
| 设备音量、诊断等指令 | 云端命令链保留;安全的本地动作可同时暴露为端侧 FC | 平台命令 + 设备白名单执行器 | 扩展 |
| 授权撤回停止 | 心跳返回 stop,设备关闭 SDK;必须补充服务端强制终止或凭证吊销 | 平台发令,火山/设备执行 | 关键改造 |
| 双轨录音 | 需验证 SDK 是否提供近端采集和远端解码 PCM 回调 | 设备录制 + 对象存储 | 有条件迁移 |
| 用量与费用 | 增加 License、AI 对话时长和模型用量来源;区分估算与账单 | 平台费用域 | 改造 |
| 诊断工作台 | 房间/Task 改为产品、设备、Bot、License、会话、传输和事件 | 平台 | 改造 |
| 配置灰度与回滚 | 增加 `hardware_agent` 运行时;按设备稳定分桶 | 平台 | 扩展 |
| 固件发布 | SDK、License 兼容、产品信息和安全存储纳入固件门禁 | 平台 + 设备 | 扩展 |
| 隐私导出/删除 | 平台数据链路保留;新增火山会话历史/设备数据删除与凭证吊销对账 | 平台主导 | 扩展 |
| 发布候选验收 | 替换 RTC 三模式矩阵,新增硬件 SDK 真机矩阵 | 平台验收工具 | 改造 |
## 5. 终端改造
### 5.1 新增硬件智能体运行时模块
建议固件同时保留两个实现:
```text
VoiceRuntime
├── LegacyRTCRuntime 现有 RTC + 平台 StartVoiceChat
└── VolcHardwareAgentRuntime 新硬件智能体 SDK
```
平台配置返回显式 `voice_runtime`:
```json
{
"voice_runtime": "volc_hardware_agent",
"runtime_revision": 12,
"transport": "high_quality",
"minimum_sdk_version": "<现场冻结版本>"
}
```
设备只在完整校验配置后切换运行时;切换失败回退上一有效配置。不要根据 `engine_mode` 或固件版本自动推断。
### 5.2 凭证与激活
设备侧需要保存:
- 火山 `instance_id`、`product_key`、`device_name`;
- 动态注册换取的设备独享 `device_secret`;
- 当前凭证版本、注册时间和最后成功鉴权时间。
安全要求:
- `product_secret`只允许保存在平台KMS或Secret挂载中,不能进入设备固件、应用、安装包或设备日志;
- 设备使用平台设备身份调用`/device/v3/hardware-agent-credentials:provision`,由平台完成DynamicRegister并下发本设备的`device_secret`;
- `device_secret` 使用安全芯片、TEE 或设备密钥封装后持久化;
- 日志、遥测和崩溃转储不得输出任何 Secret 或完整签名;
- 必须验证设备 Secret 的轮换、吊销和售后换板流程。
### 5.3 会话状态机
建议复用现有通话状态:
```text
PREPARING -> SESSION_READY -> STARTING_AGENT -> ACTIVE
-> FAILED
ACTIVE -> STOPPING -> COMPLETED
-> FAILED
```
数据库可继续把 `SESSION_READY` 对外兼容映射为 `ROOM_ISSUED`,但新 `/device/v3` 契约应使用真实语义,不再暴露不存在的 RTC Room。
终端事件映射:
| 火山/SDK 事件 | 平台动作 |
|---|---|
| SDK 初始化成功 | 更新遥测,不代表会话成功 |
| `session.created` | 保存火山会话标识,等待会话配置 |
| `session.updated` | 调用平台 activate,允许采集和播放 |
| 用户最终 ASR | 写入本地可靠字幕队列 |
| Agent 最终字幕 | 写入本地可靠字幕队列 |
| Function Call | 白名单校验后端侧执行,或转发平台工具网关 |
| Agent 错误/断开 | 上报稳定错误码,按策略恢复或停止 |
| 正常关闭 | 调用平台 stop 并冲刷字幕、用量和录音队列 |
## 6. 平台接口设计
现有 `/device/v2` 的创建响应把 `rtc` 定义为必填对象。为了不破坏量产固件,建议新增 `/device/v3/agent-sessions`,不要修改 v2 字段语义。
### 6.1 准备会话
`POST /device/v3/agent-sessions`
请求沿用设备 HMAC 和幂等键,声明:
- `capabilities.hardware_agent=true`;
- SDK 版本、传输方案、音频格式;
- 是否支持字幕可靠队列、动态会话配置、凭证轮换、近/远端 PCM 录制;
- 网络和设备时间。
响应建议:
```json
{
"conversation_id": "...",
"status": "SESSION_READY",
"runtime": {
"provider": "volc_hardware_agent",
"bot_id": "<已关联 Bot>",
"transport": "high_quality",
"session_nonce": "<短期、单次>",
"config_digest": "sha256:...",
"wait_for_session_update": true
},
"session_config": {
"dialog_id": "<平台生成的非个人标识>",
"system_context": "<已授权且脱敏的本次背景>",
"tool_scope_token": "<短期令牌>"
},
"privacy": {},
"heartbeat_interval_sec": 10,
"expires_at": "..."
}
```
响应不得返回 `product_secret` 或 `device_secret`。
### 6.2 激活会话
`POST /device/v3/agent-sessions/{conversation_id}/activate`
请求包含 `provider_session_id`、`connected_at`、`transport`、`bot_id`、`config_digest` 和设备侧关联号。平台核对准备记录和配置摘要后进入 `ACTIVE`。
### 6.3 心跳
继续使用严格递增序号,但把 `rtc` 改为:
```json
{
"agent_link": {
"connected": true,
"input_active": true,
"output_active": true,
"provider_session_id": "...",
"reconnect_count": 0
}
}
```
平台继续返回 `continue`、`stop`、`stop_after_turn`;新增 `rotate_provider_credential` 和 `refresh_session_config` 前,必须有显式设备能力位。
### 6.4 字幕
复用 `/device/v2/conversations/{id}/turns:batch` 或在 v3 提供同构接口。只上传最终字幕,不把增量字幕写入长期存储。保持 `turn_key + sequence_no` 去重和冲突检测。
### 6.5 停止
设备先请求 SDK 停止并停止采集,再调用平台 stop。平台在以下任一条件满足后收敛 `COMPLETED`:
- 收到可信服务端结束事件;
- 设备报告关闭成功且心跳租约结束;
- 强制终止接口确认成功。
如果只能依赖设备报告,授权撤回和设备失控时无法做到服务端 fail-close,不能直接通过生产隐私门禁。
## 7. 后端模块与数据模型
### 7.1 运行时抽象
现有 `voiceprovider.Client` 的 `Start/Query/Stop` 契约只适合 RTC VoiceChat。建议新增:
```go
type ConversationRuntime interface {
Prepare(context.Context, PrepareRequest) (PrepareResult, error)
Terminate(context.Context, TerminateRequest) error
Reconcile(context.Context, SessionRef) (RuntimeState, error)
}
```
- `LegacyVoiceChatRuntime` 封装现有 `voiceprovider.Client`;
- `VolcHardwareAgentRuntime` 不负责创建音频会话,只负责设备/Bot 配置同步、服务端终止、事件对账和用量拉取;
- 通话状态机、幂等、Outbox、审计和租约留在 `orchestration`。
### 7.2 新表建议
`hardware_agent_device_binding`
- `device_id`、`provider`、`instance_ref`、`product_ref`;
- `provider_device_name`、`hardware_id_digest`;
- `bot_ref`、`license_ref`、`credential_version`;
- `status`、`registered_at`、`revoked_at`、`last_verified_at`。
`conversation_runtime_session`
- `conversation_id`、`runtime_type`、`transport`;
- `provider_session_ref`、`provider_device_ref`、`bot_ref`;
- `session_nonce_digest`、`config_digest`;
- `status`、`connected_at`、`last_event_at`、`ended_at`;
- 唯一约束:一个通话最多一个活动运行时会话;同一供应商会话只能映射一个通话。
`hardware_agent_config_sync`
- 平台版本 ID、Bot/产品引用、期望摘要、实际摘要;
- 同步状态、供应商请求引用、最后错误码和验证时间;
- 用于发现火山控制台手工修改造成的配置漂移。
### 7.3 事件归一化
扩展现有 `provider_event`,归一化为:
- `hardware_agent.session.started`;
- `hardware_agent.session.updated`;
- `hardware_agent.transcript.final`;
- `hardware_agent.function_call`;
- `hardware_agent.usage.final`;
- `hardware_agent.session.stopped`;
- `hardware_agent.session.failed`。
设备上传事件和服务端回调必须使用不同 `source_type`,最终以 `provider_event_key` 和话轮键双层去重。
## 8. 工具、记忆和业务动作
### 8.1 工具分类
| 类型 | 示例 | 执行位置 | 控制要求 |
|---|---|---|---|
| 设备本地无副作用 | 查询电量、读取音量 | 设备 | 白名单、参数校验 |
| 设备本地可逆动作 | 调整音量、切换灯效 | 设备 | 范围限制、结果回传 |
| 平台只读工具 | 检索已确认记忆、读取已授权日程 | 平台工具网关 | 会话短令牌、租户/长者反查 |
| 平台有副作用工具 | 创建提醒、联系家人、修改资料 | 平台业务 API | 用户确认、幂等键、权限和审计 |
| 高风险动作 | 删除数据、转账、医疗决策 | 不直接开放给模型 | 使用既有人工流程 |
### 8.2 工具网关安全
- 工具调用必须映射到活动通话,不能只信任模型传来的 `subject_id`;
- 参数使用固定 JSON Schema,拒绝未知字段;
- 每个调用有稳定 `call_id`,平台按 `conversation_id + call_id` 幂等;
- 返回内容限制长度并做提示词注入隔离;
- 工具超时返回结构化降级结果,不中断音频主链;
- 全量记录工具名、结果状态、耗时和资源版本,不记录不必要的敏感正文。
## 9. 隐私、录音和删除
### 9.1 授权撤回
当前系统在撤回字幕、原录音、故事整理或个性化记忆授权时,会把活动通话写入停止状态并投递可靠停止任务。迁移后必须实现等价闭环:
1. 平台事务内写入撤回、通话停止意图、Outbox 和审计;
2. 在线设备通过心跳/SSE 命令立即关闭 SDK 会话;
3. 平台调用火山服务端终止或吊销接口兜底;
4. 未确认停止前禁止新建会话和工具访问;
5. 超时进入阻断告警和人工处置,而不是静默标记完成。
### 9.2 双轨录音
这是迁移中最可能发生范围变化的功能。PoC 必须确认:
- 能否取得送入 SDK 前的近端 PCM;
- 能否取得 SDK 解码后、播放前的远端 PCM;
- 打断、重连、丢包隐藏和采样率变化后时间轴能否对齐;
- SDK 是否允许在本地进行受授权的录制;
- 若使用火山云端录制,能否满足近/远轨分离、短期访问、删除和审计要求。
如不能取得远端 PCM,不能把混音录音冒充现有双轨录音。第一阶段应明确限定为 `transcript_only`,待产品接受范围变更或供应商能力补齐后再开放录音。
### 9.3 删除和凭证吊销
主体隐私删除增加两个子任务:
- 删除或匿名化火山侧与该设备/会话相关的可识别数据;
- 吊销设备智能体凭证和 License 绑定,并保存最小删除证明。
供应商无删除/吊销 API 时,需要人工控制台证据和对账任务,但该方式不适合大规模量产。
## 10. 用量、费用与 License
现有七类用量可保留,但新增来源维度:
```text
source = provider_callback | device_session | provider_bill
runtime = legacy_voicechat | volc_hardware_agent
```
建议新增类别:
- `HARDWARE_AGENT_LICENSE`:设备 License 购买、激活、到期和转移;
- `HARDWARE_AGENT_VOICE`:AI 对话时长;
- `HARDWARE_AGENT_MODEL`:模型推理额度;
- 视频、声音复刻、联网问答等扩展资源按实际开通单独归类。
设备上报只用于实时额度预估,最终账单以火山账单/用量接口为准,并执行会话、设备、账期三级对账。火山产品页显示 License 按设备提供不同对话时长和模型额度,实际采购价格、超额规则、License 回收和换机政策必须在合同中冻结。[火山硬件智能体产品页](https://www.volcengine.com/product/ConversationalAI-Embedded)
## 11. 灰度迁移步骤
### 阶段 0:供应商能力封板
在编码前取得正式 SDK、API、License 和数据处理条款,完成第 13 节问题闭环。输出一份签字的能力矩阵,未确认项不能用口头承诺替代。
### 阶段 1:独立真机 PoC
- 1 个测试产品、1 个 Bot、3 台候选板;
- 跑通高质量 SDK 初始化、动态注册、建连、打断、重连、字幕和结束;
- 验证近/远端 PCM、强制停话、凭证吊销、License 更换;
- 暂不连接生产家庭/长者数据。
### 阶段 2:平台双运行时
- 新增运行时抽象、表和 `/device/v3`;
- 旧 v2/RTC 链路保持不变;
- 新设备只接固定 Bot,先打通授权、状态、字幕、内容处理和诊断;
- 火山配置先由人工控制台维护,平台保存摘要和漂移检查。
### 阶段 3:个性化与工具
- 接入动态会话参数和冻结背景;
- 接入平台记忆 MCP/Function Calling;
- 接入角色、模型、音色、Bot 的发布和回滚;
- 接入用量、License、隐私删除和供应商对账。
### 阶段 4:灰度试点
- 内部设备 -> 友好家庭 -> 小比例真实家庭;
- 每个设备的运行时由不可变灰度版本决定;
- 出现阻断问题时只切回 `legacy_rtc`,不修改历史通话运行时;
- 灰度期间两个运行时分别统计成功率、首响、打断、重连、字幕、费用和投诉。
### 阶段 5:主链切换与退役
只有新链路连续通过真机、隐私、费用和运行恢复门禁后,才把新出厂设备默认设为硬件智能体。旧 `StartVoiceChat` 代码、RTC Token 签发和模板不能立即删除,至少保留到全部在役设备完成升级或退出支持期。
## 12. 验收与回滚门禁
建议新的真机最小矩阵至少覆盖 3 台量产候选设备、6 通会话:
1. 正常完整通话;
2. 用户打断和 Agent 被打断;
3. Wi-Fi/蜂窝或批准网络切换恢复;
4. 平台受控停止;
5. 活动通话中授权撤回;
6. 设备断电重启后的字幕队列恢复和资源回收。
每通必须核对:
- 平台通话、火山会话、设备和 Bot 关联唯一;
- 音频上行、下行、首响、打断和恢复符合现场冻结标准;
- 最终字幕完整、顺序稳定、重放不重复;
- 背景和工具调用严格限制在当前租户、长者和通话;
- 结束后 SDK、License 占用、平台租约和工具令牌全部释放;
- 设备、平台、火山、字幕、用量和隐私证据引用完整。
建议上线 SLO 在试点前冻结,不在代码中写死。至少包含:建连成功率、首次回复时延、打断成功率、异常重连成功率、字幕完整率、停止收敛时延、重复计费率、供应商账单差异和凭证吊销生效时延。
回滚规则:
- 回滚只影响新会话;活动会话按原运行时完成或受控停止;
- 数据库中的 `runtime_type` 和配置摘要不可改写;
- 设备切回旧运行时前必须确认旧 RTC SDK 和配置仍在;
- 无法强制停话、出现跨租户工具访问、删除不收敛或凭证泄露时,立即停止硬件智能体新会话灰度。
## 13. 必须向火山确认的问题
以下问题未得到书面答案前,不建议承诺完整替换日期:
1. 是否提供按设备或会话的服务端强制终止接口?设备离线时多久生效?
2. `device_secret` 是否支持轮换、吊销、批量禁用和换板迁移?
3. 产品、设备、Bot 绑定和 License 是否都有 OpenAPI,而不是只能操作控制台?
4. 是否有服务端会话开始、结束、最终字幕、错误和用量回调?签名和重放保护是什么?
5. 是否允许传入平台生成的 `conversation_id/dialog_id` 并在回调和账单中原样返回?
6. 动态会话参数可以覆盖哪些 Bot 配置?何时生效?是否保证收到 `session.updated` 后再开始识别?
7. 高质量 SDK 是否同时提供近端输入 PCM 和远端播放 PCM 回调?采样率、声道和时间戳如何定义?
8. Function Calling/MCP 的服务端鉴权、超时、重试、幂等和最大正文限制是什么?
9. License 的激活、到期、换机、回收、离线宽限和超额计费规则是什么?
10. 能否按设备、Bot、会话、租户标签导出用量并与账单对账?
11. 会话音频、字幕、日志和模型上下文分别保留多久?如何导出和删除?数据地域在哪里?
12. SDK 支持的芯片、编译器、RTOS/Linux、RAM/Flash/CPU、AEC/AGC/ANS、网络端口和证书更新要求是什么?
13. 高质量和低负载方案能否在同一产品/设备上切换?切换是否会改变 Bot、License、回调和计费语义?
14. 量产并发、建连 QPS、设备数、Bot 数和回调吞吐的配额如何申请和压测?
## 14. 主要风险
| 风险 | 影响 | 控制措施 |
|---|---|---|
| 产品级 Secret 被固件提取 | 同产品设备可被仿冒或批量注册 | 一次性安全注册、设备独享 Secret、安全存储、吊销演练 |
| 火山控制台配置漂移 | 平台记录与实际 Bot 行为不一致 | 平台配置摘要、定时对账、变更工单、生产禁止无审计手改 |
| 只能由设备报告会话结束 | 授权撤回时不能服务端强制停止 | 以供应商强停/吊销能力作为上线硬门槛 |
| 字幕只有设备单路回传 | 断电或固件故障造成内容缺口 | 本地加密可靠队列、服务端回调双源、终稿补传和差异告警 |
| SDK 无远端 PCM | 双轨录音功能无法等价迁移 | PoC 提前验证;不满足时明确限定 `transcript_only` |
| License 与内部设备解绑不同步 | 换机失败、成本浪费或越权使用 | License 台账、绑定状态机、每日对账和售后流程 |
| Bot 数量或动态参数受限 | 现有多租户灰度模型无法直接复制 | 少量基础 Bot + 会话动态参数;配置版本与供应商摘要冻结 |
| 工具调用越权或重复 | 读取他人记忆或执行重复动作 | 会话范围令牌、服务端反查、Schema、幂等、确认和审计 |
## 15. 评审建议
甲方方案评审时可以把范围分成三层:
- 必须等价迁移:正常语音、打断、设备绑定、授权门禁、通话状态、最终字幕、摘要/故事、人工记忆、额度、诊断和受控停止;
- 需要供应商能力确认:服务端强停、设备凭证吊销、字幕/用量服务端回调、动态会话参数、MCP 鉴权;
- 可能需要范围调整:双轨录音、三种旧 `engine_mode`、RTC 房间级诊断和旧 VoiceChat 模板。
推荐先让甲方确认“硬件对话智能体只替换语音会话执行层,现有业务平台继续作为控制面和数据主库”。确认这条边界后,再进入 SDK PoC 和接口改造,能避免后续把火山控制台误当成整个业务平台替代品。
+476
View File
@@ -0,0 +1,476 @@
# 设备注册与绑定接口简版
按当前 SaaS 代码整理,适用协议 `ONBOARDING-1.2-SAAS`。
正常流程:**后台登记并放行 → 设备联网认证 → 打开手机绑定页 → 用户登录并提交绑定 → 设备按键确认 → 保存绑定结果 → 激活语音服务。**
`register` 成功表示设备身份认证通过;`binding_state=BOUND` 才表示绑定成功;`activation_state=READY` 表示语音服务已就绪。
## 阅读约定
- 每个接口只列用途、输入和输出。输入示例为请求 JSON;输出示例为响应 `data` 的主要字段,已省略部分状态和时间字段。
- `<…>` 都是占位值,须替换为实际返回值或设备生成值;UUID、密钥、签名不能照抄占位值。版本号也必须使用最新返回值。
- 路径前加实际 SaaS 域名,例如 `https://saas.example`。请求使用 `Content-Type: application/json`;下面 POST 请求统一携带 `X-Request-ID`(UUID v4)。
- 设备接口除校时外,需要设备签名请求头;手机接口使用浏览器 Cookie,兑换入口后的 POST 还需 `X-CSRF-Token`,并按同源请求调用;运维接口需要运维登录凭证和 `X-Tenant-ID`。具体签名见[设备注册接口说明](设备注册接口说明.md)。
- 这是代码中的接口清单,实际可用性取决于部署开关;短信登录需要已配置并开启短信服务。
响应外层统一如下,后文的“输出”放在 `data` 中:
```json
{"ok":true,"code":"OK","message":"","request_id":"<本次请求UUID>","server_time_ms":1789092000000,"recovery_action":"NONE","retry_after_ms":0,"data":{}}
```
## 一、后台预登记:运维后台或生产工装调用
### 1. 登记一台生产设备
`POST /api/v1/ops/device-inventory`
**用途:** 在平台建立设备档案,生成设备编号和凭据交付任务。MAC、型号、硬件版本用于识别设备;批次用于追溯。另需请求头 `Idempotency-Key`,避免重复建档。
输入:
```json
{"factory_mac":"AABBCCDDEEFF","hardware_model":"KP01","hardware_revision":"A1","batch_no":"20260911-01"}
```
输出:设备生产编号、平台编号、交付任务和是否放行。
```json
{"device_id":"KP01-AABBCCDDEEFF","platform_device_id":"<平台设备UUID>","enrollment_job_id":"<交付任务UUID>","released":false}
```
### 2. 获取设备认证凭据交付包
`POST /api/v1/ops/device-inventory/{id}/credential-deliveries`
**用途:** 将设备认证密钥加密交给生产工装。`{id}` 使用上一步的 `platform_device_id`;公钥属于接收工装。
输入:
```json
{"enrollment_job_id":"<交付任务UUID>","receiver_public_key_pem":"<工装RSA公钥PEM>"}
```
输出:加密交付包。工装解密后可取得 `device_id`、`device_key_b64`、`key_version` 等设备认证资料。
```json
{"algorithm":"RSA-OAEP-SHA256+A256GCM","enrollment_job_id":"<交付任务UUID>","wrapped_key_b64":"<加密的数据密钥>","ciphertext_b64":"<凭据密文>","expires_at_ms":1789092600000}
```
### 3. 确认凭据写入并放行
`POST /api/v1/ops/device-inventory/{id}/release`
**用途:** 确认设备凭据已写入并读回校验,允许设备开始联网认证。`evidence_ref` 是校验记录的引用。
输入:
```json
{"enrollment_job_id":"<交付任务UUID>","evidence_ref":"factory-check/20260911/001","expected_key_version":1}
```
输出:该设备已放行。
```json
{"device_id":"KP01-AABBCCDDEEFF","platform_device_id":"<平台设备UUID>","enrollment_job_id":"<交付任务UUID>","released":true}
```
## 二、设备认证与绑定:设备调用
### 4. 获取平台时间和注册挑战
`POST /api/v1/device/time-challenges`
**用途:** 取得可信时间和本次注册需要的一次性凭证。`boot_id` 标识本次启动,`client_nonce` 是设备生成的 16 字节随机数,以 Base64URL 无填充编码。
输入:
```json
{"device_id":"KP01-AABBCCDDEEFF","key_version":1,"boot_id":"<本次启动UUID>","client_nonce":"<16字节随机数的Base64URL>"}
```
输出:带签名的校时数据包。设备验签并解码后读取 `challenge_id`、`server_time_ms`、`expires_at_ms`,同时核对设备编号、启动编号和随机数。
```json
{"payload_b64":"<校时数据的Base64URL>","mac_hex":"<平台响应签名>"}
```
### 5. 设备注册/启动认证
`POST /api/v1/device/register`
**用途:** 校验设备身份,记录本次启动和软件版本,返回绑定及激活状态。设备编号通过请求头 `X-Device-ID` 传入。首次联网和重新启动都调用。
输入:
```json
{"challenge_id":"<上一步challenge_id>","boot_id":"<本次启动UUID>","hardware_model":"KP01","hardware_revision":"A1","firmware_version":"1.0.0","sdk_version":"1.0.0"}
```
输出:协议版本和当前设备状态。下面表示认证通过,但尚未绑定。
```json
{"contract_version":"ONBOARDING-1.2-SAAS","state":{"device_id":"KP01-AABBCCDDEEFF","binding_state":"UNBOUND","activation_state":"NOT_STARTED","next_action":"WAIT_BIND"}}
```
### 6. 创建一次绑定设置会话
`POST /api/v1/device/setup-sessions`
**用途:** 为本次手机绑定建立临时会话。`local_setup_id` 由设备生成,表示本次设置入口;`transport_profile` 固定为 `LOCAL_WEB`。
输入:
```json
{"boot_id":"<本次启动UUID>","local_setup_id":"<本次设置入口UUID>","transport_profile":"LOCAL_WEB"}
```
输出:云端会话编号,有效期最长 15 分钟;`poll_after_ms` 表示建议多久后查询状态。
```json
{"setup_id":"<设置会话UUID>","expires_at_ms":1789092900000,"poll_after_ms":2000,"mode":"ENROLL"}
```
### 7. 获取手机绑定页面入口
`POST /api/v1/device/handoffs`
**用途:** 获取手机绑定页地址和一次性入口令牌。设备生成 `verifier`,将其 SHA256 结果以 Base64URL 无填充编码后,作为 `code_challenge`。
输入:
```json
{"setup_id":"<设置会话UUID>","local_setup_id":"<本次设置入口UUID>","code_challenge":"<verifier的SHA256结果的Base64URL>"}
```
输出:页面地址、一次性令牌和到期时间。令牌最长 90 秒有效,只能兑换一次;`verifier` 保留在设备侧,由设备一并交给手机。
```json
{"entry_url":"https://saas.example/onboarding/entry","handoff_token":"<一次性令牌>","expires_at_ms":1789092090000}
```
手机实际打开:
```text
https://saas.example/onboarding/entry#token=<handoff_token>&verifier=<设备生成的verifier>
```
### 8. 查询绑定进度/领取平台命令
`POST /api/v1/device/poll`
**用途:** 设备定期查询是否有人申请绑定,以及是否有激活配置。`known_revision` 是设备已知的状态版本,首次可传 0。
输入:
```json
{"setup_id":"<设置会话UUID>","boot_id":"<本次启动UUID>","known_revision":0}
```
输出示例:手机已经提交绑定申请,平台要求设备提示用户按键确认。`payload` 中的确认字段用于下一接口。
```json
{"state":{"binding_state":"CLAIM_PENDING"},"command":{"command_id":"<绑定申请UUID>","type":"CONFIRM_CLAIM","expires_at_ms":1789092120000,"payload":{"claim_id":"<绑定申请UUID>","challenge":"<平台按键挑战>","binding_digest":"<平台绑定摘要>","expected_binding_generation":0,"masked_account":"尾号1234"}},"poll_after_ms":2000}
```
没有命令时 `command=null`。绑定后可能返回 `APPLY_ACTIVATION`,其中包含激活任务、配置版本、配置包及校验值;见第四部分。
### 9. 设备按键确认绑定
`POST /api/v1/device/claim-confirmations`
**用途:** 用户真实按下设备确认键后,设备上报确认,平台正式建立归属关系。前四个字段取自上一步命令,`press_event_id` 标识本次实际按键。
输入:
```json
{"claim_id":"<绑定申请UUID>","challenge":"<平台按键挑战>","binding_digest":"<平台绑定摘要>","expected_binding_generation":0,"press_event_id":"<本次按键事件UUID>"}
```
输出:`BOUND` 表示正式绑定成功;`binding_generation` 是绑定代数,绑定或解绑发生后会变化。
```json
{"claim_id":"<绑定申请UUID>","already_applied":false,"state":{"binding_state":"BOUND","binding_generation":1,"next_action":"RESUME_ACTIVATION"}}
```
### 10. 设备确认已保存绑定结果
`POST /api/v1/device/state-acks`
**用途:** 设备将最新平台状态保存到本地后回执。状态版本、绑定代数和摘要使用平台返回值;`local_generation` 是本次启动的本地保存序号,不能倒退。
输入:
```json
{"device_revision":5,"binding_generation":1,"local_generation":1,"state_hash":"<平台返回的state_hash>"}
```
输出:平台已收到保存回执;设备继续查询状态,等允许后关闭临时设置入口。
```json
{"accepted_revision":5,"setup_can_close":false,"close_after_ms":30000,"next_action":"POLL_FOR_CLOSE"}
```
### 11. 关闭本次临时设置入口
`POST /api/v1/device/setup-close`
**用途:** 绑定流程收尾,关闭临时设置入口。正常完成时等待 `poll` 返回 `setup_can_close=true`,再传 `SUCCESS`;其他原因可为 `TIMEOUT`、`CANCELLED`、`NETWORK_SWITCH`。
输入:
```json
{"setup_id":"<设置会话UUID>","reason":"SUCCESS","final_revision":5}
```
输出:允许关闭;关闭入口不等于语音激活完成,激活可继续进行。
```json
{"close_allowed":true,"state":{"binding_state":"BOUND"}}
```
## 三、手机绑定页面:SaaS 前端调用
用户看到的页面是 `/onboarding/setup`,正常由带一次性凭证的 `/onboarding/entry` 进入。登录账号及目标设备由会话确定,无需前端在绑定请求中再传手机号或设备编号。
### 12. 兑换设备提供的一次性入口
`POST /api/v1/web/handoffs/exchange`
**用途:** 手机用设备提供的令牌和校验值建立绑定会话。服务端通过 Cookie 保持该会话,并返回后续 POST 使用的 `csrf_token`。
输入:
```json
{"token":"<设备提供的handoff_token>","verifier":"<设备提供的verifier>"}
```
输出:注意此接口的响应 `data` 内还有一层 `data`,这是当前代码的实际结构。
```json
{"csrf_token":"<后续请求使用的CSRF值>","data":{"setup_id":"<设置会话UUID>","device_id":"KP01-AABBCCDDEEFF","sms_enabled":true,"state":{"binding_state":"UNBOUND","binding_generation":0}}}
```
### 13. 发送手机登录验证码
`POST /api/v1/web/auth/sms/send`
**用途:** 给用户手机号发送登录验证码。`phone_e164` 含国家区号,`purpose` 固定为 `LOGIN`。
输入:
```json
{"phone_e164":"+8613800000000","purpose":"LOGIN"}
```
输出:验证码任务编号、过期时间、多久后可重发;不会在接口响应中返回验证码。
```json
{"sms_challenge_id":"<短信验证UUID>","expires_at_ms":1789092300000,"resend_after_ms":60000}
```
### 14. 验证短信并登录
`POST /api/v1/web/auth/sms/verify`
**用途:** 验证用户输入的短信验证码,建立登录状态。
输入:
```json
{"sms_challenge_id":"<短信验证UUID>","code":"123456"}
```
输出:登录成功,并返回更新后的 CSRF 值;前端后续请求改用该值。
```json
{"csrf_token":"<更新后的CSRF值>","data":{"authenticated":true,"setup_id":"<设置会话UUID>"}}
```
### 15. 查询可以绑定的家庭和使用者
`POST /api/v1/web/profiles`
**用途:** 列出当前用户有权选择的家庭和设备使用者。
输入:
```json
{}
```
输出:家庭和使用者列表。选择目标后,后续绑定使用 `elder_id`。
```json
{"items":[{"household_id":"<家庭UUID>","household_name":"我的家庭","elder_id":"<使用者UUID>","preferred_name":"爸爸"}]}
```
### 16. 创建家庭/设备使用者
`POST /api/v1/web/profiles/create`
**用途:** 没有可选使用者时创建档案。新建家庭传 `household_name`;已有家庭则改传 `household_id`。`preferred_name` 是使用者称呼。
输入示例:新建家庭和使用者。
```json
{"household_name":"我的家庭","preferred_name":"爸爸"}
```
输出:新建的家庭和使用者编号。
```json
{"household_id":"<家庭UUID>","elder_id":"<使用者UUID>"}
```
### 17. 提交设备绑定申请
`POST /api/v1/web/claims`
**用途:** 申请把当前设备绑定给选中的使用者。`elder_id` 来自上面两个接口,`expected_binding_generation` 来自最新设备状态。
输入:
```json
{"elder_id":"<使用者UUID>","expected_binding_generation":0}
```
输出:申请编号和状态。`CLAIM_PENDING` 表示等待设备按键确认,还没有正式绑定。
```json
{"claim_id":"<绑定申请UUID>","expires_at_ms":1789092120000,"state":{"binding_state":"CLAIM_PENDING","binding_generation":0}}
```
### 18. 查询手机页面上的绑定进度
`POST /api/v1/web/setup/status`
**用途:** 手机刷新等待确认、已绑定和激活进度。`device_saved` 表示设备是否已确认保存绑定结果。
输入:
```json
{}
```
输出示例:设备已经绑定并保存结果。
```json
{"binding_id":"<正式绑定UUID>","device_saved":true,"authenticated":true,"state":{"device_revision":5,"binding_generation":1,"binding_state":"BOUND"}}
```
### 19. 手机确认已收到绑定成功结果
`POST /api/v1/web/setup/ack`
**用途:** 告诉平台手机页面已收到绑定成功结果,配合设备完成设置收尾。三个字段均取自上一步响应。
输入:
```json
{"binding_id":"<正式绑定UUID>","binding_generation":1,"received_revision":5}
```
输出:回执已接收。
```json
{"accepted":true}
```
## 四、绑定后的语音激活:设备继续调用
配置由平台后台任务准备,通过前面的 `POST /api/v1/device/poll` 下发。设备不用直接调用供应商注册接口。
`poll` 的激活命令输出示例(仍是响应 `data` 内的字段):
```json
{"command":{"command_id":"<激活任务UUID>","type":"APPLY_ACTIVATION","payload":{"activation_id":"<激活任务UUID>","config_version":1,"config_digest":"<配置摘要>","config_b64":"<编码后的配置包>","config_mac":"<配置签名>","key_version":1,"config_saved":false,"engine_initialized":false}}}
```
配置包解码后包含设备编号、绑定代数、配置版本、到期时间、IoT 连接资料、单设备密钥、机器人及 RTC 配置。设备校验后保存,用于连接语音服务。
### 20. 确认已保存激活配置
`POST /api/v1/device/activation-acks`
**用途:** 设备校验并保存配置后回执。任务、版本和摘要取自激活命令;绑定代数取自当前状态。
输入:
```json
{"activation_id":"<激活任务UUID>","config_version":1,"binding_generation":1,"local_generation":2,"config_digest":"<配置摘要>"}
```
输出:配置保存已确认。
```json
{"accepted":true,"config_version":1}
```
### 21. 申请执行初始化/首次通话测试
`POST /api/v1/device/activation-attempts`
**用途:** 执行 SDK 初始化或首次通话测试前,申请本次执行许可。`attempt_id` 由设备生成;`stage` 为 `ENGINE_INIT` 或 `CONVERSATION_TEST`。首次通话必须由用户主动发起,并传 `user_initiated=true`。
输入示例:申请初始化 SDK。
```json
{"activation_id":"<激活任务UUID>","config_version":1,"attempt_id":"<本次执行UUID>","stage":"ENGINE_INIT","user_initiated":false}
```
输出:本次执行许可和到期时间。许可最长 120 秒,首次通话测试最长 60 秒。
```json
{"activation_id":"<激活任务UUID>","attempt_id":"<本次执行UUID>","stage":"ENGINE_INIT","permit_token":"<短时执行许可>","permit_expires_at_ms":1789092120000,"maximum_test_seconds":60}
```
### 22. 上报初始化/首次通话测试结果
`POST /api/v1/device/activation-results`
**用途:** 上报执行结果。`outcome` 为 `SUCCESS`、`DEFINITE_FAILURE` 或 `UNKNOWN`;成功时 `sdk_result` 必须为 0。
输入示例:真实首次通话完成后上报成功,`evidence` 引用平台已记录的会话和事件。初始化结果使用 `stage=ENGINE_INIT`,`evidence` 可传 `{}`。
```json
{"activation_id":"<激活任务UUID>","attempt_id":"<首次通话执行UUID>","stage":"CONVERSATION_TEST","sdk_result":0,"outcome":"SUCCESS","evidence":{"conversation_id":"<测试会话UUID>","started_event_id":"<开始事件ID>","first_audio_event_id":"<首音频事件ID>","ended_event_id":"<结束事件ID>"}}
```
输出示例:首次通话验收通过,设备可正常使用。仅初始化成功不会直接返回 `READY`。
```json
{"duplicate":false,"state":{"binding_state":"BOUND","activation_state":"READY","next_action":"ENTER_READY"}}
```
首次通话本身复用既有 `POST /device/v3/agent-sessions` 及会话事件接口;不能只调用本节的结果接口就完成验收。原有通话字段和新增测试许可字段见[实施说明中的首通流程](onboarding-implementation.md#6-激活首通和人工恢复)。
## 五、页面恢复和可选操作
这些操作按需要使用,不是每次首次绑定都必须调用。
| 接口 | 用途 | 输入示例 | 输出示例(响应 data,节选) |
|---|---|---|---|
| `GET /api/v1/web/setup/session` | 页面刷新后恢复设置会话,取得新的 CSRF 值 | 无正文,浏览器携带设置 Cookie | `{"csrf_token":"<新CSRF值>","data":{"authenticated":true,"state":{"binding_state":"CLAIM_PENDING"}}}` |
| `POST /api/v1/web/setup/auth-link` | 使用已有 OIDC 登录状态,作为短信登录的另一种方式 | 正文 `{}`,另带 `Authorization: Bearer <登录令牌>` | `{"csrf_token":"<新CSRF值>","data":{"authenticated":true,"setup_id":"<设置会话UUID>"}}` |
| `POST /api/v1/web/claims/cancel` | 用户取消自己的待确认绑定申请 | `{"claim_id":"<绑定申请UUID>","expected_binding_generation":0}` | `{"state":{"binding_state":"UNBOUND"}}` |
| `GET /api/v1/web/onboarding/devices` | 设置完成后,通过登录账号查询已绑定设备 | 无正文,携带账号 Cookie 或 OIDC 登录令牌 | `{"items":[{"device_id":"KP01-AABBCCDDEEFF","activation_state":"READY","household_name":"我的家庭","preferred_name":"爸爸"}]}` |
## 最常用字段怎么理解
| 字段 | 含义/来源 |
|---|---|
| `device_id` | 生产设备编号,例如 `KP01-AABBCCDDEEFF` |
| `platform_device_id` | 平台设备 UUID,用于后台设备接口中的 `{id}` |
| `boot_id` | 设备本次启动编号,重启后重新生成 |
| `setup_id` | 本次临时绑定流程编号,由平台创建 |
| `elder_id` | 使用者档案编号,由手机选择或创建 |
| `claim_id` | 待设备按键确认的绑定申请编号 |
| `binding_id` | 已正式生效的绑定记录编号,由手机状态接口返回 |
| `binding_generation` | 绑定代数,用于区分绑定、解绑后的不同归属状态 |
| `device_revision` | 平台状态版本,用于判断拿到的是哪一版状态 |
| `activation_id` | 激活任务编号,通过设备 `poll` 命令取得 |
| `attempt_id` | 设备生成的一次初始化或首次通话执行编号 |
接口路径与请求字段依据[当前 HTTP 路由](../internal/onboarding/http.go)、[设备流程](../internal/onboarding/setup.go)、[手机流程](../internal/onboarding/web.go)、[短信登录](../internal/onboarding/sms.go)、[激活流程](../internal/onboarding/activation.go)及[生产登记](../internal/onboarding/inventory.go)核对。完整 31 项首次接入操作(含管理、退出和异常处理)见[字段清单](onboarding-api-fields.md)。
+261
View File
@@ -0,0 +1,261 @@
# 设备注册接口说明
适用版本:`ONBOARDING-1.2-SAAS`;对应当前仓库实现,OpenAPI `0.78.0`。
设备注册用于验证设备身份、登记本次启动和固件/SDK版本,并返回当前绑定及激活进度。首次联网和每次重新启动时使用本流程。注册成功后,用户绑定与语音服务激活继续按照返回状态处理。
## 1. 调用前提与顺序
设备必须已在 SaaS 完成生产登记、认证凭据交付和放行,并持有以下信息:
| 信息 | 示例 | 用途 |
|---|---|---|
| device_id | KP01-AABBCCDDEEFF | 生产设备编号,格式为 KP01- 加 12 位大写 MAC |
| device_key_b64 | 工装交付的密钥编码 | 解码为 raw 32 字节后,用于 HMAC 签名和校时验签 |
| key_version | 1 | 当前生效认证密钥的版本 |
| hardware_model | KP01 | 库存登记的设备型号 |
| hardware_revision | A1 | 库存登记的硬件修订号 |
只有生产模式为 `physical_confirm` 且已放行的设备使用此协议;旧设备维持原有协议。未登记的编号不会通过本注册接口自动建档。
调用顺序:
1. 本次启动生成一个 `boot_id`,整个启动期间保持不变。
2. C01 获取校时挑战,验证响应签名及随机数,取得可信时间和 `challenge_id`。
3. C02 使用设备密钥签名并提交注册请求。
4. 根据返回状态继续:未绑定设备创建设置会话;已绑定设备进入恢复激活或就绪流程。
下文以 `https://saas.example` 表示部署后的 SaaS 域名。示例中的编号、时间和版本均为说明用值,联调时须使用实际返回值。
## 2. C01:获取校时挑战
```http
POST /api/v1/device/time-challenges
Content-Type: application/json
```
此接口不要求请求 HMAC。建议使用 HTTPS;服务端为启动校时保留 HTTP 访问能力。设备必须验证响应中的 TIME1 签名,才可采用其中的时间。C02 必须使用 HTTPS。
请求正文:
```json
{
"device_id": "KP01-AABBCCDDEEFF",
"key_version": 1,
"boot_id": "10000000-0000-4000-8000-000000000001",
"client_nonce": "AAECAwQFBgcICQoLDA0ODw"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| device_id | string | 是 | 与生产登记一致的设备编号 |
| key_version | integer | 是 | 当前生效密钥版本,必须大于等于 1 |
| boot_id | string | 是 | 标准小写 UUID v4;本次启动标识 |
| client_nonce | string | 是 | 16 字节随机数的 Base64URL 无填充编码;用于确认校时响应属于本次请求 |
`X-Request-ID` 可选;传入时必须是标准小写 UUID v4,省略时由平台生成。它用于追踪 C01 请求;校时挑战本身按设备、密钥版本、boot_id、client_nonce 去重。
成功响应的 `data`:
```json
{
"payload_b64": "<校时JSON原始字节的Base64URL无填充编码>",
"mac_hex": "<64位小写十六进制签名>"
}
```
校验公式:
```text
raw_key = Base64URLDecode(device_key_b64)
expected_mac = HexLower(HMAC-SHA256(raw_key, UTF8("TIME1\n" + payload_b64)))
```
其中 `\n` 是一个 LF 换行字节。先核对 mac_hex,再解码 payload_b64。解码内容为:
```json
{
"device_id": "KP01-AABBCCDDEEFF",
"boot_id": "10000000-0000-4000-8000-000000000001",
"client_nonce": "AAECAwQFBgcICQoLDA0ODw",
"challenge_id": "10000000-0000-4000-8000-000000000002",
"server_time_ms": 1789099200000,
"expires_at_ms": 1789099320000
}
```
设备核对 device_id、boot_id、client_nonce 与本地请求完全一致,保存 challenge_id,并用 server_time_ms 建立时间基准。挑战从签发起有效 120 秒,不能重复用于不同的成功注册操作;同一 client_nonce 重试不会延长有效期。过期后换新的 client_nonce 重新获取。
## 3. C02:提交设备注册
| 项目 | 内容 |
|---|---|
| 请求地址 | `POST https://saas.example/api/v1/device/register` |
| 调用方 | 已登记、已放行的设备 |
| 认证方式 | DEVREQ1 / HMAC-SHA256 |
| 请求正文 | JSON,UTF-8 无 BOM,最大 8 KiB |
| URL参数 | 不接受 query 参数 |
| 时间要求 | 请求时间与服务端时间差不超过正负 120 秒 |
**请求头**
| 请求头 | 必填 | 示例/用途 |
|---|---|---|
| Content-Type | 是 | `application/json` |
| X-Device-ID | 是 | `KP01-AABBCCDDEEFF`,生产设备编号 |
| X-Key-Version | 是 | `1`,签名使用的当前密钥版本 |
| X-Timestamp-Ms | 是 | 当前可信 UTC 毫秒时间戳 |
| X-Nonce | 是 | 每次发送新生成的 16 字节随机数,Base64URL 无填充编码 |
| X-Request-ID | 是 | 标准小写 UUID v4,标识本次注册业务请求;网络重试保持不变 |
| Authorization | 是 | `Device-HMAC-SHA256 <64位小写十六进制签名>` |
整数请求头使用标准十进制,不带正号、空格或前导零。X-Nonce 与 C01 的 client_nonce 用途不同:前者用于每次 HTTP 发送防重放,后者用于校验对应的校时响应。
**请求正文**
```json
{
"challenge_id": "10000000-0000-4000-8000-000000000002",
"boot_id": "10000000-0000-4000-8000-000000000001",
"hardware_model": "KP01",
"hardware_revision": "A1",
"firmware_version": "1.2.0",
"sdk_version": "1.0.0"
}
```
| 字段 | 类型 | 必填 | 数据用途和约束 |
|---|---|---|---|
| challenge_id | string | 是 | C01 响应中取得的挑战 UUID v4;必须属于同一设备、密钥版本和 boot_id,且尚未使用、未过期 |
| boot_id | string | 是 | 本次启动的 UUID v4;与 C01 相同,用于识别重启和关闭旧临时设置流程 |
| hardware_model | string | 是 | 设备型号;必须与 SaaS 生产登记完全一致 |
| hardware_revision | string | 是 | 硬件修订号;必须与 SaaS 生产登记完全一致 |
| firmware_version | string | 是 | 当前固件版本,1–32 个 UTF-8 字节;用于版本记录和诊断 |
| sdk_version | string | 是 | 当前 SDK 版本,1–64 个 UTF-8 字节;用于版本记录和诊断 |
device_id 通过请求头传入。设备注册由库存确定租户,不需要用户登录令牌、手机号、家庭编号或使用者编号。不要提交设备明文密钥、供应商 ProductSecret 或 Wi-Fi 密码。请求中不接受未定义的字段。
## 4. C02 签名计算
先把最终要发送的 JSON 编码为 UTF-8 原始字节,再计算 `body_hash = SHA256(body)` 的小写十六进制值。按以下顺序构造 **9 行**文本,行间只用 LF,末尾不加换行:
```text
DEVREQ1
POST
/api/v1/device/register
<X-Device-ID>
<X-Key-Version>
<X-Timestamp-Ms>
<X-Nonce>
<X-Request-ID>
<body_hash>
```
```text
signature = HexLower(HMAC-SHA256(raw_key, UTF8(上述9行文本)))
Authorization = "Device-HMAC-SHA256 " + signature
```
raw_key 是解码后的 32 字节设备密钥,不能直接用 device_key_b64 的字符串字节。签名中的路径不包含域名。签名后不能再修改正文中的空格、换行或字段顺序。
项目提供 [Python 签名调用工具](../scripts/onboarding-request.py) 和 [固定测试向量](../test/fixtures/onboarding-devreq1.json)。将上面的注册正文保存为 UTF-8 无 BOM 的 `register.json`,使用实际有效的 challenge_id、boot_id 和设备版本:
```powershell
python scripts/onboarding-request.py --device-id KP01-AABBCCDDEEFF --key-file device-key.b64 --key-version 1 --path /api/v1/device/register --body-file register.json --request-id 10000000-0000-4000-8000-000000000003
```
命令默认只输出本次签名请求头;加 `--send https://你的SaaS域名` 才会发送一次请求。工具使用运行它的电脑当前时间,使用前须保证电脑时间准确。固定测试向量使用合成密钥和固定时间,用于校验算法,不能直接发送到真实设备。
## 5. C02 成功响应
HTTP 200,以下为一台尚未绑定设备的示例:
```json
{
"ok": true,
"code": "OK",
"message": "",
"request_id": "10000000-0000-4000-8000-000000000003",
"server_time_ms": 1789099201000,
"recovery_action": "NONE",
"retry_after_ms": 0,
"data": {
"contract_version": "ONBOARDING-1.2-SAAS",
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"platform_device_id": "10000000-0000-4000-8000-000000000004",
"device_revision": 3,
"binding_generation": 0,
"binding_state": "UNBOUND",
"activation_state": "NOT_STARTED",
"next_action": "WAIT_BIND",
"state_hash": "<当前状态的64位小写SHA256摘要>"
}
}
}
```
示例中的 state_hash 是占位说明;设备须使用真实响应中的摘要。
| 响应字段 | 用途 |
|---|---|
| ok / code / message | 请求是否成功及业务结果说明 |
| request_id | 与请求对应,方便联调和日志定位 |
| server_time_ms | 响应生成时的服务器 UTC 毫秒时间 |
| recovery_action / retry_after_ms | 错误恢复建议及推荐退避时间,成功时为 NONE / 0 |
| data.contract_version | 本次接口使用的协议版本 |
| state.device_id | 设备认证使用的生产编号 |
| state.platform_device_id | 既有 SaaS 管理接口使用的设备公开 UUID,与生产编号分开 |
| state.device_revision | 当前状态版本,设备同步和状态 ACK 使用 |
| state.binding_generation | 当前绑定代数;解绑、重新绑定后变化,用于使旧绑定请求和配置失效 |
| state.binding_state | UNBOUND=未绑定;CLAIM_PENDING=等待设备物理确认;BOUND=已绑定 |
| state.activation_state | NOT_STARTED=未开始开通;CONTACT_POSSIBLE=供应商执行中或结果待确认;REGISTERED_OBSERVED=已观察到注册成功、待设备验收;READY=验收完成;RECONCILE_REQUIRED=待对账;BLOCKED=当前开通受阻;RESERVED 为预留状态 |
| state.next_action | WAIT_BIND=继续设置和绑定流程;RESUME_ACTIVATION=恢复激活;ENTER_READY=进入就绪流程 |
| state.state_hash | 当前绑定和激活状态摘要,用于后续状态持久化回执 |
注册成功表示 SaaS 已接受设备本次启动认证。是否已经绑定看 binding_state,是否完成通话验收看 activation_state。C02 不返回新的设备密钥或供应商语音凭据。
## 6. 服务端处理和重试规则
服务端先校验设备、放行状态、租户、密钥、HMAC、时间和随机数,再校验挑战归属及版本字段。成功后消费挑战,记录 boot_id、最近注册时间、固件版本和 SDK 版本,并返回设备状态。
新的 boot_id 会关闭上一次启动的临时设置会话并取消待确认的绑定申请;已经完成的正式绑定保留。
| 情况 | 设备端处理 |
|---|---|
| 请求超时、回包丢失 | 保持原 X-Request-ID 和原始正文,更新 X-Timestamp-Ms、X-Nonce 并重新签名 |
| 前一次已经成功 | 在同一启动、绑定代数及有效权限范围内,24 小时内可返回缓存的原业务结果;外层响应时间重新生成 |
| 相同请求号更改正文 | 返回 REQUEST_BODY_CONFLICT;不得用同一个请求号提交另一份版本信息或挑战 |
| 挑战无效或过期 | 重新 C01,取得新挑战后使用新的 X-Request-ID 发起新的注册请求 |
| 设备重启 | 生成新的 boot_id,重新执行 C01/C02,不重放上一次启动的注册请求 |
| 注册缓存已过期或绑定代数已变化 | 恢复当前启动流程并查询最新状态,不依赖旧缓存结果 |
## 7. 常见错误
| HTTP | code | 说明与处理 |
|---|---|---|
| 400 | REQUEST_ID_REQUIRED | C02 缺少或使用了非 UUID v4 请求号 |
| 400 | HTTPS_REQUIRED | C02 必须通过 HTTPS 访问 |
| 400 | QUERY_FORBIDDEN | 去掉 URL query 参数 |
| 400 | INVALID_JSON / VALIDATION_FAILED | 检查正文结构、重复字段、型号、硬件版本和版本字符串长度 |
| 401 | DEVICE_AUTHENTICATION_FAILED | 检查编号、已放行状态、密钥版本、raw key、签名、时间和 nonce;重复发送同一 nonce 也会被拒绝 |
| 403 | RESOURCE_ACCESS_DENIED | 当前租户、设备或业务操作不可访问,核对设备状态 |
| 409 | CHALLENGE_INVALID | 挑战不属于当前设备/密钥/启动,或已消费、过期;重新 C01 |
| 409 | REQUEST_BODY_CONFLICT | 同一请求号对应的正文发生变化 |
| 409 | BINDING_GENERATION_CONFLICT | 当前启动或绑定状态已变化,恢复当前流程 |
| 410 | CHALLENGE_EXPIRED | C01 返回的同一校时挑战已到期;换新的 client_nonce |
| 410 | REQUEST_RESULT_EXPIRED | 注册请求的缓存结果已超过有效期 |
| 413 | REQUEST_TOO_LARGE | 请求正文超过 8 KiB |
| 415 | JSON_REQUIRED | Content-Type 必须为 application/json |
| 429 | RATE_LIMITED | 按 Retry-After / retry_after_ms 退避;当前建议等待 60 秒 |
| 503 | AUTHENTICATION_UNAVAILABLE / RATE_LIMIT_UNAVAILABLE | 认证防重放或限速依赖暂不可用,保留业务请求并退避重试 |
| 500 | INTERNAL_ERROR | 内部处理异常,记录 request_id,按原请求号与正文重试 |
失败时统一返回 `ok=false`、对应 code,data 为 null。恢复动作是通用建议;对于 CHALLENGE_INVALID 等具体错误,应按上表执行。
## 8. 注册后的接口
未绑定设备接着调用 `POST /api/v1/device/setup-sessions`,创建手机绑定入口所需的临时会话。后续完整字段见 [首次接入接口字段清单](onboarding-api-fields.md),部署与开关见 [实施说明](onboarding-implementation.md)。
本说明依据当前源码核对:[注册处理](../internal/onboarding/setup.go)、[设备认证及校时](../internal/onboarding/auth.go)、[签名计算](../internal/onboarding/crypto.go)、[HTTP响应](../internal/onboarding/http.go)。当前是代码接口说明,实际域名和开放范围由部署配置决定。
@@ -0,0 +1,24 @@
# 设备端接口对齐记录
更新:2026-09-12。当前代码已按[设备端接口文档](设备端注册绑定接口.md)同步实现,原有差异清单已由下表取代。
| 位置 | 当前实现 |
|---|---|
| 协议及外层 | ONBOARDING-1.1;不返回 recovery_action、retry_after_ms、next_action |
| 公共状态 | 仅设备编号、状态版本、绑定代数、绑定状态、激活状态、状态摘要 |
| C01 | 无时间戳请求签名;HTTP专用入口;成功根对象仅 payload_b64、mac_hex,单设备 TIME1 HMAC |
| C03 / C05 | 删除 mode 和轮询收尾辅助字段;known_revision 必填 |
| 激活配置 | 命令 payload 仅 config_b64、config_mac;包内使用 iot.product_secret;设备 SDK 执行注册 |
| C07 | 仅 accepted_revision、setup_can_close;重复回执重新计算关闭条件,保留原保存时间 |
| C08 | 仅任务、配置版本、执行编号输入;返回 allowed、执行编号、30秒到期时间、state;平台按任务事实确定阶段 |
| C09 | 仅任务、执行编号、SDK_REGISTER/CONVERSATION_TEST、SDK返回码、结果分类输入;返回任务、state、reconcile_required |
| C10 | 仅任务、保存序号、配置版本输入;注册成功后确认保存结果;返回 accepted、state |
| C11 | 继续检查最终版本与收尾条件;关闭临时入口不删除绑定 |
默认设备 SDK 直接执行与上报首通观察结果;不要求额外上传 evidence。如采用平台既有 v3 通话服务,则平台依据已关联的会话、开始、首音频和结束事件核验结果,不向 C09 增加字段。设备供应商密钥不通过 C09 上传;既有平台代管通话仍需要其原有凭据配置。
原文保留的 TRANSFER_PENDING 等枚举不代表本次新增了完整转移流程。当前首次绑定下发的命令为 CONFIRM_CLAIM 和 APPLY_ACTIVATION。短信通道与真实板卡、供应商 SDK 需使用真实环境分别验证。
本地验证:全量 Go 测试、go vet、10 个模块的隔离 PostgreSQL/Redis 集成测试、前端 TypeScript 和生产构建通过。设备 HTTP 测试覆盖 C01–C11 成功字段、缺失或额外字段拒绝、HMAC、HTTPS限制、注册和首通、未知结果、跨绑定重放及收尾等待。
线上结果见[发布记录](onboarding-release-20260912.md)。
+798
View File
@@ -0,0 +1,798 @@
# 设备端注册、绑定与激活接口说明
更新日期:2026-09-12。
依据原始《AI电话_首次配网注册与激活_完整流程与详细设计(1)》附件 A、B、C、E、I 整理,仅列设备直接调用 SaaS 的 C01~C11。按本次要求省去辅助流程控制字段,不添加原文没有规定的请求或响应字段。
本文件保留字段列表、中文含义、获取来源和示例。服务端已按此接口契约实现,版本为 `ONBOARDING-1.1`,并按本次要求移除三个辅助流程控制字段。实现与验证记录见[接口对齐说明](设备端接口_文档与当前代码差异.md);线上发布结果另见[发布记录](onboarding-release-20260912.md)。
## 通用规则
- 设备已完成生产登记,持有出厂写入的 `device_id`、32 字节 `device_key`、`key_version`。
- 全部接口使用 POST,正文为 JSON,不带 query 参数;普通报文上限 8 KiB,配置包上限 32 KiB。
- C01 是无可信时间时可通过专用 HTTP 入口调用的校时接口。其余接口使用严格验证证书和主机名的 HTTPS。
- `<…>` 为占位值,必须使用实际返回值或生成值;示例中的编号、版本、时间不是可直接请求线上服务的数据。
- `string` 为字符串,`integer` 为整数,`boolean` 为 true/false,`object` 为 JSON 对象。UUID 按原文为标准 36 字符 UUID v4;Base64URL 无填充编码末尾不带 `=`。
- 版本、计数按原文范围为 0~2147483647,时间为 Unix 毫秒。字段表中的全部输入均为原文必填字段。
### 请求头
C02~C11 使用下列认证头。C01 的设备编号和密钥版本放在正文中,不要求先提交可信时间戳或普通设备请求签名。
| 字段 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- |
| `Content-Type` | 正文格式 | 固定 `application/json`,C01 也使用。 |
| `X-Device-ID` | 生产设备编号 | 从出厂配置读取,例如 `KP01-AABBCCDDEEFF`。 |
| `X-Key-Version` | 设备认证密钥版本 | 从本机已保存凭据读取,十进制填写。 |
| `X-Timestamp-Ms` | 本次发送时间 | C01 验证并校时后,读取当前可信 UTC 毫秒时间。 |
| `X-Nonce` | 本次发送随机数 | 每次 HTTP 发送生成新的 16 字节安全随机数,做 Base64URL 无填充编码。 |
| `X-Request-ID` | 业务请求编号 | 由设备生成 UUID v4;同一次业务操作重试保留,原始正文也保留。 |
| `Authorization` | 设备请求认证码 | 使用本机 `device_key` 计算签名,填写 `Device-HMAC-SHA256 <64位小写十六进制HMAC>`。 |
签名按附件 B 的 DEVREQ1 规则计算,以下 9 行以 LF 连接,末尾不再加换行:
```text
DEVREQ1
<大写HTTP方法>
<实际path>
<device_id>
<key_version十进制>
<timestamp_ms十进制>
<nonce的Base64URL>
<request_id>
<原始请求正文的SHA256小写十六进制>
```
密钥使用原始 32 字节,不发送密钥本身。网络重试更新随机数、发送时间和签名,但不重新生成同一操作的业务编号、按键事件编号或执行编号。
### 普通响应外层
C02~C11 使用此封装,C01 不使用。成功时读取 `data`,失败时读取 `code`、`message`;设备归属和激活结果以业务状态为准。
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `ok` | boolean | 本次请求是否成功 | 平台生成;不能单独用它判断设备已完成绑定或激活。 |
| `code` | string | 业务结果码 | 平台返回,成功为 `OK`,失败为对应错误码。 |
| `request_id` | string(UUID) | 请求追踪编号 | 平台返回与本次请求关联的编号,供查找请求记录。 |
| `data` | object | 成功业务输出 | 字段按各接口下表读取。 |
| `server_time_ms` | integer | 平台时间 | 平台生成;原文成功响应中提供,不能替代 C01 的首次可信校时。 |
| `message` | string | 错误中文说明 | 失败时由平台提供,便于提示原因。 |
```json
{
"ok": true,
"code": "OK",
"request_id": "<请求UUID>",
"data": {},
"server_time_ms": 1789092000000
}
```
失败示例:
```json
{
"ok": false,
"code": "CLAIM_EXPIRED",
"request_id": "<请求UUID>",
"message": "绑定确认已过期,请重新发起"
}
```
### 公共设备状态 `state`
下表相对于 `data.state`。按本次删减后的口径列出,状态版本、绑定代数和摘要必须来自同一版返回状态。
| 字段 | 类型 | 中文含义 | 来源/后续使用 |
| --- | --- | --- | --- |
| `device_id` | string | 生产设备编号 | 来自平台生产记录,设备与本机编号核对。 |
| `device_revision` | integer | 平台设备状态版本 | 由平台维护;后续用于 C05、C07、C11。 |
| `binding_generation` | integer | 绑定代数 | 平台随绑定关系变化而更新,初始 0,首次绑定后 1;用于防止旧归属信息被继续使用。 |
| `binding_state` | string | 绑定状态 | 平台根据归属关系返回,取值见下表。 |
| `activation_state` | string | 激活状态 | 平台根据激活任务返回,只有 READY 表示服务已就绪。 |
| `state_hash` | string | 本版状态摘要 | 平台按附件 I.1 计算;设备保存该版状态后在 C07 原样回传。 |
| 字段 | 取值及中文含义 |
| --- | --- |
| `binding_state` | `UNBOUND` 未绑定;`CLAIM_PENDING` 等待按键;`BOUND` 已绑定;`TRANSFER_PENDING` 转移处理中。 |
| `activation_state` | `NOT_STARTED` 未开始;`RESERVED` 已预留;`CONTACT_POSSIBLE` 可能已执行;`REGISTERED_OBSERVED` 已确认注册;`READY` 已就绪;`RECONCILE_REQUIRED` 待对账;`BLOCKED` 被阻止。 |
`state_hash` 按原文对以下内容以 LF 连接后计算 SHA256,输出小写十六进制,末尾无换行:
```text
STATE1
<device_id>
<device_revision>
<binding_generation>
<binding_state>
<activation_state>
```
`device_revision` 是平台状态版本,`binding_generation` 是归属代数,`local_generation` 是设备本地保存序号,`config_version` 是激活配置版本;不能互相代填。
## 接口总览
下面路径前统一加 `/api/v1/device`。
| 编号 | POST 路径 | 用途 |
| --- | --- | --- |
| C01 | `/time-challenges` | 校时并获取注册挑战 |
| C02 | `/register` | 认证设备并登记启动 |
| C03 | `/setup-sessions` | 创建设置会话 |
| C04 | `/handoffs` | 获取可交给手机的一次性绑定入口 |
| C05 | `/poll` | 查询状态及命令 |
| C06 | `/claim-confirmations` | 设备按键确认绑定 |
| C07 | `/state-acks` | 确认已保存绑定状态 |
| C08 | `/activation-attempts` | 申请一次 SDK 执行许可 |
| C09 | `/activation-results` | 上报激活执行结果 |
| C10 | `/activation-acks` | 确认激活结果已保存 |
| C11 | `/setup-close` | 记录设置收尾并关闭入口 |
## C01 校时并取得注册挑战
`POST /api/v1/device/time-challenges`
用途:让设备在时钟不正确时先取得可信时间,再使用严格 HTTPS。只返回时间和挑战,不返回用户资料、登录令牌或火山密钥。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `device_id` | string | 是 | 生产设备编号 | 读取出厂配置:`KP01-` 加 12 位大写十六进制 MAC。 |
| `key_version` | integer | 是 | 认证密钥版本 | 读取本机保存的凭据版本,不是密钥内容。 |
| `boot_id` | string(UUID) | 是 | 本次启动编号 | 设备每次启动生成并保留,后续 C02、C03、C05 沿用。 |
| `client_nonce` | string | 是 | 本次校时随机数 | 设备生成 16 字节安全随机数并做 Base64URL 编码,每次校时重试换新值。 |
输入示例:
```json
{
"device_id": "KP01-AABBCCDDEEFF",
"key_version": 1,
"boot_id": "<本次启动UUID>",
"client_nonce": "<16字节随机数的Base64URL>"
}
```
### 输出字段
下表为校时响应根对象。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `payload_b64` | string | 校时数据包 | 平台将下方六个字段序列化并编码;设备用原字符串验签后解码。 |
| `mac_hex` | string | 校时数据认证码 | 平台用本设备密钥计算 TIME1 HMAC,设备使用同一密钥验证。 |
输出示例:
```json
{
"payload_b64": "<校时数据包的Base64URL>",
"mac_hex": "<64位小写十六进制HMAC>"
}
```
`payload_b64` 验签后解码字段:
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `device_id` | string | 设备编号回显 | 核对等于本次请求及本机编号。 |
| `boot_id` | string(UUID) | 启动编号回显 | 核对等于设备本次启动编号。 |
| `client_nonce` | string | 随机数回显 | 核对等于本次新请求的随机数。 |
| `challenge_id` | string(UUID) | 注册挑战编号 | 由平台生成并返回,保存后传入 C02。 |
| `server_time_ms` | integer | 经过认证的平台时间 | 平台可信时钟生成;全部校验通过后设置设备时钟。 |
| `expires_at_ms` | integer | 挑战到期时间 | 平台签发时间加 120 秒,到期后重新 C01。 |
```json
{
"device_id": "KP01-AABBCCDDEEFF",
"boot_id": "<本次启动UUID>",
"client_nonce": "<本次请求随机数>",
"challenge_id": "<注册挑战UUID>",
"server_time_ms": 1789092000000,
"expires_at_ms": 1789092120000
}
```
校验公式:`HMAC-SHA256(device_key, UTF8("TIME1\n" + payload_b64))`,其中 `\n` 表示一个 LF 换行。附件 B 要求设备核对全部回显字段、单调计时 RTT 不超过 10 秒、时间不早于固件或已保存可信时间允许的下限;通过后校时,再开始 HTTPS。
## C02 设备认证与启动登记
`POST /api/v1/device/register`
用途:认证已有设备身份并登记本次启动;重复请求不新建设备、不清空既有归属。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `challenge_id` | string(UUID) | 是 | 注册挑战 | 取 C01 验签后解码对象中的 `challenge_id`。 |
| `boot_id` | string(UUID) | 是 | 本次启动编号 | 沿用同一次 C01 的 `boot_id`。 |
| `hardware_model` | string,≤32字节 | 是 | 设备型号 | 从出厂配置读取,按真实型号填写。 |
| `hardware_revision` | string,≤16字节 | 是 | 硬件版本 | 读取出厂记录的硬件修订号。 |
| `firmware_version` | string,≤32字节 | 是 | 固件版本 | 读取设备当前实际运行的固件版本。 |
| `sdk_version` | string,≤64字节 | 是 | SDK版本 | 读取设备实际集成的 SDK 版本信息。 |
输入示例:
```json
{
"challenge_id": "<C01挑战UUID>",
"boot_id": "<本次启动UUID>",
"hardware_model": "KP01",
"hardware_revision": "A1",
"firmware_version": "1.0.0",
"sdk_version": "1.0.0"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `state` | object | 当前设备状态 | 平台返回已有记录的最新状态,完整字段见公共状态表。 |
| `contract_version` | string | 协议版本 | 原文规定 `ONBOARDING-1.1`;用于确认两侧接口口径。 |
输出示例:
```json
{
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 3,
"binding_generation": 0,
"binding_state": "UNBOUND",
"activation_state": "NOT_STARTED",
"state_hash": "<本版状态SHA256摘要>"
},
"contract_version": "ONBOARDING-1.1"
}
```
## C03 创建设置会话
`POST /api/v1/device/setup-sessions`
用途:为本次设置建立平台会话;已绑定设备可恢复上下文,但不能借此重新认领。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `boot_id` | string(UUID) | 是 | 本次启动编号 | 沿用已完成 C02 的启动编号。 |
| `local_setup_id` | string(UUID) | 是 | 本地设置编号 | 设备为本次设置流程生成;重试和 C04 沿用。 |
| `transport_profile` | string | 是 | 本地交互方式 | 按原文固定传 `LOCAL_WEB`。 |
输入示例:
```json
{
"boot_id": "<本次启动UUID>",
"local_setup_id": "<设备生成的本地设置UUID>",
"transport_profile": "LOCAL_WEB"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `setup_id` | string(UUID) | 平台设置会话编号 | 平台生成,后续传 C04、C05、C11;不同于设备生成的 local_setup_id。 |
| `expires_at_ms` | integer | 设置会话到期时间 | 平台生成;默认 15 分钟,原文绝对上限 30 分钟,设备以返回期限为准。 |
| `poll_after_ms` | integer | 建议轮询间隔,毫秒 | 平台返回;绑定期间原文默认 2000。 |
| `state` | object | 当前设备状态 | 平台返回,完整字段见公共状态表。 |
输出示例:
```json
{
"setup_id": "<平台设置会话UUID>",
"expires_at_ms": 1789092900000,
"poll_after_ms": 2000,
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 3,
"binding_generation": 0,
"binding_state": "UNBOUND",
"activation_state": "NOT_STARTED",
"state_hash": "<本版状态SHA256摘要>"
}
}
```
## C04 取得一次性手机入口
`POST /api/v1/device/handoffs`
用途:设备取得 SaaS 绑定入口,并交给当前已授权的本地会话。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `setup_id` | string(UUID) | 是 | 平台设置会话编号 | 取 C03 的 `data.setup_id`。 |
| `local_setup_id` | string(UUID) | 是 | 本地设置编号 | 沿用 C03 请求中的同名字段。 |
| `code_challenge` | string | 是 | 入口校验摘要 | 设备生成32字节随机值并编码为 verifier;对 verifier 的 ASCII 字节算 SHA256,再编码为 Base64URL。 |
输入示例:
```json
{
"setup_id": "<C03设置会话UUID>",
"local_setup_id": "<C03本地设置UUID>",
"code_challenge": "<32字节SHA256结果的Base64URL>"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `handoff_token` | string | 一次性入口令牌 | 平台生成32字节随机值并编码;设备把它放进入口链接。 |
| `expires_at_ms` | integer | 令牌到期时间 | 平台生成,原文有效期90秒;不能由设备自行延长。 |
| `entry_url` | string | SaaS绑定页面地址 | 由平台固定配置的 HTTPS 白名单入口。 |
输出示例:
```json
{
"handoff_token": "<平台一次性令牌>",
"expires_at_ms": 1789092090000,
"entry_url": "https://saas.example/onboarding/entry"
}
```
设备自己保留 `verifier`,不是向平台多传一个字段。交给手机的完整链接为:
```text
<entry_url>#token=<handoff_token>&verifier=<设备生成的verifier>
```
## C05 查询状态和命令
`POST /api/v1/device/poll`
用途:设备查询当前状态,领取绑定确认命令或激活配置;查询本身不代表用户按键或 SDK 执行完成。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `setup_id` | string(UUID) | 是 | 平台设置会话编号 | 取 C03 返回的设置会话编号。 |
| `boot_id` | string(UUID) | 是 | 本次启动编号 | 沿用本次已经注册的启动编号。 |
| `known_revision` | integer | 是 | 设备已知状态版本 | 取最近返回的 `state.device_revision`;首次不知道时传0。按原文该字段必填。 |
输入示例:
```json
{
"setup_id": "<C03设置会话UUID>",
"boot_id": "<本次启动UUID>",
"known_revision": 3
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `state` | object | 当前设备状态 | 平台返回,完整字段见公共状态表。 |
| `command` | object 或 null | 当前命令 | 平台产生;没有命令时为 null,原文兼容 NONE 类型。命令子字段见下表。 |
| `poll_after_ms` | integer | 下次轮询间隔,毫秒 | 由平台返回;绑定阶段默认2秒,普通待机不保持高频轮询。 |
输出示例:
```json
{
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 3,
"binding_generation": 0,
"binding_state": "UNBOUND",
"activation_state": "NOT_STARTED",
"state_hash": "<本版状态SHA256摘要>"
},
"command": null,
"poll_after_ms": 2000
}
```
### `command` 命令字段
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `command_id` | string(UUID) | 命令编号 | 平台生成,设备识别重复命令;原文未规定它必须等于申请或激活任务编号。 |
| `type` | string | 命令类型 | 原文为 NONE、CONFIRM_CLAIM、APPLY_ACTIVATION、UPDATE_STATE、CLOSE_SETUP;按返回类型处理。 |
| `expires_at_ms` | integer | 命令到期时间 | 平台生成,设备按可信时间判断有效性。 |
| `payload` | object | 命令数据 | 由平台生成;下面只展开原文明确给出的两种命令内容。 |
`CONFIRM_CLAIM` 的 `payload`:
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `claim_id` | string(UUID) | 绑定申请编号 | 平台产生,设备从此处获取后传 C06。 |
| `challenge` | string | 按键挑战 | 平台生成32字节随机值编码,真实按键后 C06 原样回传。 |
| `binding_digest` | string | 固定绑定内容摘要 | 平台计算64位 SHA256 小写十六进制,设备不改写,C06 原样回传。 |
| `masked_account` | string | 脱敏账号提示 | 平台生成,例如“尾号1234”,供用户确认当前申请。 |
| `expected_binding_generation` | integer | 预期绑定代数 | 平台在建立申请时确定;C06 原样回传。 |
```json
{
"command_id": "<命令UUID>",
"type": "CONFIRM_CLAIM",
"expires_at_ms": 1789092120000,
"payload": {
"claim_id": "<绑定申请UUID>",
"challenge": "<32字节随机值的Base64URL>",
"binding_digest": "<固定绑定内容SHA256>",
"masked_account": "尾号1234",
"expected_binding_generation": 0
}
}
```
`APPLY_ACTIVATION` 的 `payload` 按原文只列配置封装:
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `config_b64` | string | 激活配置包 | 平台生成配置 JSON 并编码;设备验签后解码,下方列出解码字段。 |
| `config_mac` | string | 配置认证码 | 平台计算 `HMAC-SHA256(device_key, UTF8("CFG1\n" + config_b64))` 的十六进制值;其中 \n 表示一个 LF,设备用本机密钥验证。 |
配置包验签解码后的字段,依据附件 E.2:
| 字段 | 类型 | 中文含义 | 来源/怎么用 |
| --- | --- | --- | --- |
| `contract_version` | string | 配置协议版本 | 原文为 `ONBOARDING-1.1`。 |
| `device_id` | string | 配置所属设备 | 平台读取生产记录,设备核对与本机一致。 |
| `activation_id` | string(UUID) | 激活任务编号 | 平台创建并保留;设备从配置包中取出,后续 C08、C09、C10 使用。 |
| `config_version` | integer | 激活配置版本 | 平台生成;后续 C08、C10 使用,不自行写死。 |
| `binding_generation` | integer | 配置对应绑定代数 | 平台维护;与当前公共状态核对,防止使用旧归属配置。 |
| `expires_at_ms` | integer | 配置到期时间 | 由平台签发,设备不得用过期配置开始新的首次激活;原文未在此规定统一24小时有效期。 |
| `iot` | object | 供应商注册资料 | 平台组装,子字段如下。 |
| `iot.instance_id` | string | 供应商实例编号 | 来自平台保存的实例配置。 |
| `iot.product_key` | string | 供应商产品标识 | 来自平台保存的产品配置。 |
| `iot.product_secret` | string | 受保护的产品注册密钥 | 这是原文 E.2 明确列出的字段;设备通过受保护配置取得,供原文 SDK 注册流程使用;与当前代码方案的差异见单独说明。 |
| `iot.device_name` | string | 物理设备在供应商侧的固定名称 | 由 SaaS 首次激活前生成并永久记录,设备原样使用;不随账号、重试、重启更换。 |
| `bot_id` | string | 共享智能体编号 | 来自平台配置,设备按对应 SDK 要求使用。 |
原文要求配置仅经设备 HTTPS 下发,落地使用加密 NVS,不交给手机或写入普通日志。该表没有增加当前代码中的其他运行配置字段。
## C06 设备按键确认绑定
`POST /api/v1/device/claim-confirmations`
用途:用户真实按下设备确认键后,设备提交本次确认,平台正式建立归属关系。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `claim_id` | string(UUID) | 是 | 绑定申请编号 | 取 C05 同一确认命令的 `payload.claim_id`。 |
| `challenge` | string | 是 | 按键挑战 | 取该命令的 `payload.challenge`,原样回传。 |
| `binding_digest` | string,64位hex | 是 | 绑定内容摘要 | 取该命令同名字段,原样回传。 |
| `expected_binding_generation` | integer | 是 | 预期绑定代数 | 取该命令同名字段,不能自行加一。 |
| `press_event_id` | string(UUID) | 是 | 本次真实按键事件编号 | 设备在真实按键发生时生成并保存,网络重试沿用。 |
输入示例:
```json
{
"claim_id": "<C05申请UUID>",
"challenge": "<C05按键挑战>",
"binding_digest": "<C05绑定摘要>",
"expected_binding_generation": 0,
"press_event_id": "<设备实际按键UUID>"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `claim_id` | string(UUID) | 已处理的绑定申请 | 平台返回,设备核对是本次申请。 |
| `state` | object | 处理后的归属及激活状态 | 平台返回;保存对应版本、代数和摘要后调用 C07。 |
| `already_applied` | boolean | 该确认是否已经提交过 | 由平台识别重复相同申请的结果;不可据此产生第二次绑定。 |
输出示例:
```json
{
"claim_id": "<绑定申请UUID>",
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 4,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "RESERVED",
"state_hash": "<本版状态SHA256摘要>"
},
"already_applied": false
}
```
## C07 确认绑定状态已保存
`POST /api/v1/device/state-acks`
用途:报告设备已经保存平台状态,不根据此回执修改 owner。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `device_revision` | integer | 是 | 已保存状态版本 | 取同一版 `state.device_revision`。 |
| `binding_generation` | integer | 是 | 已保存绑定代数 | 取同一版 `state.binding_generation`。 |
| `local_generation` | integer | 是 | 本地保存序号 | 由设备本地保存模块产生并在保存成功后记录,不用平台版本号代替。 |
| `state_hash` | string,64位hex | 是 | 已保存状态摘要 | 取同一版 `state.state_hash` 并核对实际保存内容。 |
输入示例:
```json
{
"device_revision": 4,
"binding_generation": 1,
"local_generation": 1,
"state_hash": "<已保存状态摘要>"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `accepted_revision` | integer | 平台接受的保存版本 | 平台确认后返回,设备可记录该版已完成回执。 |
| `setup_can_close` | boolean | 是否可以收尾 | 平台根据回执和流程条件判断;设备按结果执行收尾。 |
输出示例:
```json
{
"accepted_revision": 4,
"setup_can_close": false
}
```
原文收尾条件:页面与设备都确认后等待3秒,或设备确认后最多30秒。这里保留原文的布尔判断,不新增等待时间或后续动作字段。
## C08 申请 SDK 执行许可
`POST /api/v1/device/activation-attempts`
用途:平台先记录本次可能执行的事实,再决定是否允许本次 SDK 调用,避免不确定结果下重复执行。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `activation_id` | string(UUID) | 是 | 激活任务编号 | 从 C05 激活配置包解码后的 `activation_id` 取得。 |
| `config_version` | integer | 是 | 本次使用的配置版本 | 从同一份已验证配置的 `config_version` 取得。 |
| `attempt_id` | string(UUID) | 是 | 一次 SDK 执行编号 | 设备为本次实际执行生成并保留;本次请求重试沿用,不重新申请第二次执行。 |
输入示例:
```json
{
"activation_id": "<配置包中的激活任务UUID>",
"config_version": 1,
"attempt_id": "<设备生成的执行UUID>"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `allowed` | boolean | 是否允许本次执行 | 平台判断后返回;只有 true 才能开始此次 SDK 调用。 |
| `attempt_id` | string(UUID) | 许可对应执行编号 | 平台返回本次执行编号,后续 C09 使用。 |
| `permit_expires_at_ms` | integer | 许可到期时间 | 原文 I.1 规定许可有效期30秒;超窗重新查询,不能推断先前调用一定没有执行。 |
| `state` | object | 当前设备状态 | 平台返回,完整字段见公共状态表。 |
输出示例:
```json
{
"allowed": true,
"attempt_id": "<本次执行UUID>",
"permit_expires_at_ms": 1789092030000,
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 5,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "CONTACT_POSSIBLE",
"state_hash": "<本版状态SHA256摘要>"
}
}
```
## C09 上报激活执行结果
`POST /api/v1/device/activation-results`
用途:上报已获许可的实际 SDK 执行结果。超时或无法确定结果时上报 UNKNOWN,不能当作明确失败重复执行。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `activation_id` | string(UUID) | 是 | 激活任务编号 | 沿用 C08 所属任务编号。 |
| `attempt_id` | string(UUID) | 是 | 实际执行编号 | 沿用 C08 已申请并返回的执行编号,不在上报时重新生成。 |
| `stage` | string | 是 | 执行阶段 | 按原文 I.1 填 `SDK_REGISTER` 或 `CONVERSATION_TEST`,由设备依据本次实际执行操作确定。 |
| `sdk_result` | integer | 是 | SDK返回码 | 读取本次实际 SDK 调用或回调结果,不编造结果码。 |
| `outcome` | string | 是 | 本次结果分类 | 原文取值:`SUCCESS_OBSERVED` 已观察到成功、`DEFINITE_FAILURE` 明确失败、`UNKNOWN` 未知。 |
输入示例:
```json
{
"activation_id": "<激活任务UUID>",
"attempt_id": "<C08执行UUID>",
"stage": "SDK_REGISTER",
"sdk_result": 0,
"outcome": "SUCCESS_OBSERVED"
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `activation_id` | string(UUID) | 结果所属任务 | 平台返回,设备与本次任务核对。 |
| `state` | object | 处理后的设备状态 | 平台返回,判断仍在激活、待处理或已 READY。 |
| `reconcile_required` | boolean | 是否需要对账 | 平台根据本次结果判定;true 时不要自行开始第二次同类激活执行。 |
输出示例:
```json
{
"activation_id": "<激活任务UUID>",
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 6,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "REGISTERED_OBSERVED",
"state_hash": "<本版状态SHA256摘要>"
},
"reconcile_required": false
}
```
首次通话测试时使用 `stage=CONVERSATION_TEST`。结果分类只表示这一次执行的事实,不等同于设备最终状态;服务是否可用仍读取 `state.activation_state`。
## C10 确认激活结果已保存
`POST /api/v1/device/activation-acks`
用途:设备确认激活结果已在本地保存;重复相同回执不产生新的激活副作用。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `activation_id` | string(UUID) | 是 | 已保存结果所属任务 | 沿用配置包及 C09 对应的激活任务编号。 |
| `local_generation` | integer | 是 | 本地保存序号 | 由设备保存模块在本次结果保存成功后记录,不使用配置版本代替。 |
| `config_version` | integer | 是 | 结果对应配置版本 | 沿用本次激活实际使用的配置包版本。 |
输入示例:
```json
{
"activation_id": "<激活任务UUID>",
"local_generation": 2,
"config_version": 1
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `accepted` | boolean | 回执是否被接受 | 由平台验证后返回。 |
| `state` | object | 当前设备状态 | 平台返回,完整字段见公共状态表。 |
输出示例:
```json
{
"accepted": true,
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 6,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "REGISTERED_OBSERVED",
"state_hash": "<本版状态SHA256摘要>"
}
}
```
## C11 设置流程收尾
`POST /api/v1/device/setup-close`
用途:请求或报告本次设置流程收尾。关闭临时入口不清空已提交的绑定或激活任务。
### 输入字段
| 字段 | 类型 | 必填 | 中文含义 | 怎么获得/填写 |
| --- | --- | --- | --- | --- |
| `setup_id` | string(UUID) | 是 | 要关闭的设置会话 | 取本次 C03 返回的 `setup_id`。 |
| `reason` | string | 是 | 关闭原因 | 设备按实际情况填 SUCCESS、TIMEOUT、CANCELLED 或 NETWORK_SWITCH。 |
| `final_revision` | integer | 是 | 关闭前最后收到的状态版本 | 取最后成功收到的 `state.device_revision`,不是设备本地保存序号。 |
输入示例:
```json
{
"setup_id": "<C03设置会话UUID>",
"reason": "SUCCESS",
"final_revision": 6
}
```
### 输出字段
下表字段位于普通响应的 `data` 中。
| 字段 | 类型 | 中文含义 | 来源/设备怎么用 |
| --- | --- | --- | --- |
| `close_allowed` | boolean | 是否允许关闭 | 平台判定并返回,设备据此执行关闭。 |
| `state` | object | 当前设备状态 | 平台返回并保留正式绑定及激活事实,便于后续恢复。 |
输出示例:
```json
{
"close_allowed": true,
"state": {
"device_id": "KP01-AABBCCDDEEFF",
"device_revision": 6,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "REGISTERED_OBSERVED",
"state_hash": "<本版状态SHA256摘要>"
}
}
```
## 字段从哪里取得
| 要使用的字段 | 取值来源 |
| --- | --- |
| `device_id`、`device_key`、`key_version` | 设备出厂写入的身份及凭据。 |
| `boot_id` | 设备每次启动生成。 |
| `challenge_id` | C01 验签后的校时包。 |
| `local_setup_id` | 设备为一次本地设置生成。 |
| `setup_id` | C03 平台返回。 |
| `claim_id`、`challenge`、`binding_digest`、预期绑定代数 | 同一条 C05 确认命令,不跨命令拼接。 |
| `press_event_id` | 设备真实按键时生成。 |
| `activation_id`、`config_version` | C05 激活配置包验签解码后取得。 |
| `attempt_id` | 设备为一次 SDK 执行生成,C08 申请、C09 沿用。 |
| `local_generation` | 设备保存模块记录本地保存完成的序号。 |
| `device_revision`、`binding_generation`、`state_hash` | 平台返回的同一版公共状态。 |
## 平台如何确定 C08 执行阶段
C08 不增加阶段字段。平台按任务中已确认的事实判定:尚未观察到注册成功时,许可用于 `SDK_REGISTER`;已注册且 C10 确认保存后,许可用于 `CONVERSATION_TEST`。C09 的阶段必须匹配平台为该执行编号记录的阶段。已完成 READY 的同代绑定不再发放首次激活许可。
原文未给出部分命令的完整 payload,当前实际下发 `CONFIRM_CLAIM`、`APPLY_ACTIVATION` 两种命令,其余保留枚举不作为新增接口功能。
来源:[原始流程文档](<D:/wechat/xwechat_files/wangcong3092_983b/msg/file/2026-09/AI电话_首次配网注册与激活_完整流程与详细设计(1).docx>),重点是附件 B(认证及校时)、C(设备接口)、E(状态和配置)、I.1(逐接口输入与输出)。
@@ -0,0 +1,473 @@
# 首次配网设备认证与绑定 SaaS 修改方案
版本:方案草案 1.0 日期:2026 年 9 月 11 日
本方案用于把当前平台改造成支持设备首次认证、手机登录交接、实体按键确认绑定和独立 AI 激活的 SaaS。修改以现有 Go 服务、PostgreSQL、Redis、React、OIDC、设备绑定及供应商凭据模块为基础,分阶段交付,保留旧设备协议。
最终用户流程是:设备联网并认证,手机进入 SaaS 登录并选择使用者,用户按设备按键确认,SaaS 保存绑定,设备保存结果并回执,AI 服务随后独立开通。激活失败时显示“已绑定,AI 服务待处理”,用户不必重新配网或绑定。
本文是一份待实施的设计,不代表接口、数据库或页面已经修改。依据为用户提供的 ONBOARDING-1.1 首次接入文档、上一份差异分析和当前工作区代码。接口、数据表、配置项及模块名称标注为新增或拟新增的,均属于本方案建议。
## 1 方案采用的实现路线
| 决策 | 采用的方案 | 原因及影响 |
|---|---|---|
| 设备身份 | 保留数据库 UUID,新增唯一工厂编号和 MAC 映射 | 兼容现有业务外键、通话和管理接口 |
| 新设备认证 | 新增 DEVREQ1 验签,使用原始 32 字节密钥 | 与附件认证算法衔接;旧设备原签名和密钥解释不变 |
| 账号归属 | 延续租户、家庭、使用者模型;绑定人从当前登录态取得 | 复用权限服务,防止跨租户或任意指定 owner |
| 绑定方式 | 新设备必须走当前申请的设备按键确认 | 绑定码、运维分配等旧入口不能绕过该规则 |
| 手机登录 | 首先接入现有 OIDC 完成联调,完整交付补短信登录和注册 | 先验证绑定业务;短信未完成时不宣称覆盖附件的全部手机流程 |
| AI 激活 | 推荐保留 SaaS 代注册,再向设备下发 DeviceSecret | 复用当前供应商集成;ProductSecret 留在服务端 |
| 任务执行 | 绑定与 outbox 同事务,外部调用在独立 worker 中执行 | 回包丢失或进程退出仍能恢复原任务 |
| 上线方式 | 按设备或产线批次启用新协议,先发布兼容保护版本 | 保证旧设备持续运行,也保证新设备不能降级绕过确认 |
由于激活执行位置与原文档不同,建议把最终新契约暂命名为 `ONBOARDING-1.2-SAAS`,待双方联调后冻结。该名称是本方案的拟定版本,不是现有已发布协议。
### 1.1 与附件的明确调整
1. C01 至 C07、C11 的业务顺序和 DEVREQ1/TIME1/STATE1 计算保留;公共状态额外返回 platform_device_id,说明内部 UUID 与工厂编号的关系。
2. C08 至 C10 调整为设备引擎初始化或首通测试的许可、结果和配置保存回执;供应商注册由 SaaS worker 执行并记录证据。不得继续用设备上报 SDK_REGISTER 来表示服务端代注册结果。
3. 激活配置使用 device_secret,不下发 product_secret;完整性仍采用 CFG1,包含设备、绑定代数、任务、版本和期限。
4. OIDC 是第一阶段的账号接入路径;短信是完整交付的一部分。两者最后映射到同一平台账号和家庭权限模型。
5. 新设备进行日常通话时继续复用现有会话服务,设备入口增加显式协议路由与统一身份解析,不能让设备在进入通话阶段突然改用未经配置的另一套密钥。
若后续要求逐字段保持 ONBOARDING-1.1,则需要另行选择设备执行动态注册路线,恢复附件的 C08 至 C10 和 ProductSecret 配置语义;不能把两种执行模式混在同一个协议版本里。当前方案仅规划 SaaS 代注册这一条实施主线。
### 1.2 SaaS 与设备的工作边界
SaaS 实现库存、凭据、认证、会话交接、账号权限、绑定提交、状态回执、激活任务、对账和运维页面。设备配合生成及保存启动标识、调用新接口、按键确认指定申请、保存状态并 ACK,以及使用平台下发的运行凭据。
家庭 Wi-Fi 密码、AP/NAPT、SSID 扫描、GPIO 和本地 NVS 的实现不进入 SaaS。接口联调需要设备配合,但本方案不安排具体硬件开发。
## 2 修改后的流程及完成条件
| 阶段 | SaaS 提交的事实 | 进入下一阶段的条件 | 失败时保留什么 |
|---|---|---|---|
| 生产登记 | 库存、固定身份和受保护凭据 | 烧录校验并受控放行 | 原设备身份,不生成新设备副本 |
| 设备认证 | 时间挑战已消费、当前启动报告已更新 | 合法 HMAC、挑战、型号和设备状态 | 已有绑定和激活任务 |
| 手机交接 | setup 已建立、handoff 已兑换 | 有效入口和匹配 verifier | 设备和网络已有结果 |
| 申请绑定 | 固定账号和使用者的 PENDING claim | 当前账号有管理权限且设备未绑定 | 已完成登录,不提前建立归属 |
| 提交绑定 | 唯一有效绑定、代数、状态和激活待办 | 设备确认匹配当前 claim | 即使回包丢失,绑定仍成立 |
| 保存与收尾 | 设备和手机回执、设置入口关闭事实 | 回执条件满足或到达关闭上限 | 绑定、设备凭据和原激活任务 |
| AI 激活 | 供应商结果、运行配置和验收证据 | 按阶段检查,不以 HTTP 200 统一判断 | 账号绑定及可能已占用的供应商权益 |
手机可以在绑定完成后离开设置页。设备本地保存失败只修复落盘,AI 激活失败只修复激活。普通重配网保留绑定;解绑和转移使用独立业务流程。
## 3 数据库修改
数据库变更先增加字段和新表,再回填,再启用约束及新功能。以下为逻辑设计,正式 migration 编号在开发时按仓库最新序号分配。
### 3.1 扩展现有记录
| 现有记录 | 拟增加的数据 | 约束及用途 |
|---|---|---|
| device | enrollment_mode、onboarding_contract、factory_device_id、factory_mac、binding_generation、onboarding_revision | 旧数据默认为 legacy;工厂编号和 MAC 各自唯一;绑定代数和状态版本独立 |
| device_binding | generation、source、claim_id | device 与 generation 唯一;保留当前有效绑定唯一索引;claim_id 只对应一次提交 |
| 账号身份映射 | 统一的身份提供方、稳定主体和 user_account 关联 | 保留现有 OIDC 映射;短信身份不伪造成某个外部 OIDC 用户 |
| 审计及事件 | setup/claim/activation/attempt 的关联字段或结构化详情 | 追踪整条首次接入链路,不保存 token、验证码或密钥正文 |
onboarding_revision 在新协议中输出为 device_revision,避免更改现有 device.version、activation_revision、配置 revision 的语义。新表全部使用内部 UUID 做外键,并建立与 tenant_id 一致的组合约束。
### 3.2 新增的逻辑记录
| 拟新增表 | 关键字段 | 必须具备的约束 |
|---|---|---|
| device_inventory | device_id、factory_device_id、factory_mac、tenant_id、型号、批次、生产状态、放行记录 | MAC 和工厂编号唯一;一个库存只关联一个平台设备 |
| device_onboarding_credential | device_id、key_version、encrypted_key、nonce、key_ref、status、activated_at | device/key_version 唯一;只允许一个当前有效版本;密钥是原始 32 字节 |
| device_enrollment_job | job_id、device_id、request_digest、烧录包引用、校验及交付状态 | 重复登记不换身份;受保护交付结果可按原任务恢复 |
| device_time_challenge | device_id、key_version、boot_id、client_nonce、签发和到期时间、consumed_by_request | 绑定设备与启动;挑战只消费一次;可关联同一已提交注册请求 |
| device_onboarding_state | device_id、binding_state、activation_state、current_activation_id、last_boot_id、state_hash | 每设备一条;与 revision 和当前有效绑定在事务内一致更新 |
| device_setup_session | setup_id、device_id、boot_id、local_setup_id、mode、expires_at、absolute_expires_at、closed_at | 当前流程模式为 ENROLL 或 RECOVERY;已绑定设备只能恢复 |
| device_handoff | token_hash、setup_id、code_challenge、expires_at、consumed_at | token 哈希唯一;一次兑换;不得生成第二个可复活的入口 |
| web_setup_session | session_hash、setup_id、认证账号引用、CSRF 信息、期限、撤销时间 | 浏览器只访问当前流程;登录后旋转会话标识 |
| device_binding_claim | claim_id、device_id、setup_id、目标账号/家庭/使用者、challenge_hash、加密挑战、binding_digest、expected_generation、status、expires_at、press_event_id、result_binding_id | 每设备最多一条 PENDING;已成功申请长期去重;按键事件不能确认第二个申请 |
| device_state_snapshot / device_state_ack | device_id、revision、binding_generation、state_hash、local_generation、boot_id、保存及接收时间 | 快照不可变;重复相同 ACK 无副作用;旧 ACK 不覆盖当前状态 |
| device_setup_web_ack | setup_id、binding_id、binding_generation、received_revision、received_at | 记录页面看到了哪次绑定;与设备 ACK 独立 |
| device_activation_job | activation_id、device_id、provider_identity_id、状态、配置版本、当前 attempt、权益引用 | 每设备只有一个当前激活任务;终态和历史保留 |
| device_activation_attempt | attempt_id、activation_id、executor、stage、permit_deadline、fence_version、结果和证据 | executor 区分 SERVER/DEVICE;attempt 唯一;超时不自动授予第二次可能重复的调用 |
| provider_device_identity / activation_entitlement | 固定 device_name、供应商产品、状态、预留或对账证据 | 设备身份不因换用户重建;额度扣占必须事务化且有业务去重键 |
可以复用现有供应商凭据表保存加密 DeviceSecret,在请求供应商之前先持久化 provider_device_identity。权益模型与当前 License 内容表分开处理,不能把文件导入记录当成供应商已确认授权。
新 credential 表避免直接破坏现有 device_credential 按设备唯一的结构。旧密钥继续由旧解析器读取,新密钥由版本化解析器读取;共用加密基础时分别定义包含协议和版本的 AAD。
### 3.3 唯一约束示意
以下 SQL 用于表达约束,不是可直接执行的 migration:
```sql
CREATE UNIQUE INDEX device_binding_claim_one_pending
ON device_binding_claim (device_id)
WHERE status = 'PENDING';
CREATE UNIQUE INDEX device_binding_generation_unique
ON device_binding (device_id, generation);
CREATE UNIQUE INDEX device_activation_one_current
ON device_activation_job (device_id)
WHERE is_current = true;
```
不要在部分索引条件中使用 now()。创建新申请前,在设备锁内把已过期 PENDING 更新为 EXPIRED;后台清理只能辅助,不能作为并发正确性的前提。
### 3.4 绑定代数和历史回填
新设备初始 generation=0;首次绑定变为 1;解绑增加一次;重新绑定再增加一次。第一版转移走“原账号授权解绑,再由目标账号和设备确认新绑定”,不提供绕过确认的一步换主。
存量绑定按稳定的 bound_at、id 顺序建立迁移映射,考虑中间解绑事件,为当前归属确定唯一有效代数。不能把所有历史记录统一回填为 1。历史证据不足的设备保留 legacy 模式,待受控迁移时设置并审计基线;不凭推测补造过去的按键或验收证据。
## 4 生产登记和认证修改
### 4.1 新增生产管理入口
以下路径为本方案新增建议,由受控生产服务账号或具备相应能力的运维账号调用,鉴权与设备请求分开。
| 方法与路径 | 请求数据 | 返回和效果 |
|---|---|---|
| POST /api/v1/ops/device-inventory | factory_mac、hardware_model、hardware_revision、batch_no、目标租户;Idempotency-Key | 服务端生成 factory_device_id,建立或核对原库存;返回内部 UUID 和 enrollment_job_id |
| POST /api/v1/ops/device-inventory/{id}/credential-deliveries | enrollment_job_id、受控产线接收方和交付用途 | 返回仅产线可解密的烧录包或受控交付引用;不在普通列表返回密钥 |
| POST /api/v1/ops/device-inventory/{id}/release | 校验记录引用、expected_version | 已完成烧录校验后放行,允许首次云端认证 |
| POST /api/v1/ops/device-inventory/{id}/credential-rotations | reason、expected_key_version、交付接收方 | 创建新版本交付任务,确认后切换版本,旧版本撤销 |
同 MAC 再登记时返回同一库存和设备;型号、租户或板卡记录冲突返回 409,不静默覆盖。返修使用原登记任务或受控恢复任务;不因响应丢失重新生成密钥。烧录包有效期、访问授权和销毁均可审计,普通幂等响应只保存任务引用。
放行前设备可以处于现有 FACTORY 状态;放行后同步为 CLAIMABLE,避免被当前认证门禁拒绝。尚未通过生产校验的设备不得因请求 C01 而自动放行。
### 4.2 新鉴权组件
新增 `internal/onboardingauth`,读取以下头:X-Device-ID、X-Key-Version、X-Timestamp-Ms、X-Nonce、X-Request-ID、Authorization。DEVREQ1 九行签名按附件原始 body 字节计算;时间容差 ±120 秒;nonce 按设备和版本至少保存 5 分钟。
处理顺序固定为:限制体积和格式 → 查生产放行记录和凭据版本 → 恒定时间验签 → 时间窗口 → 原子 nonce 防重放 → 设备及租户当前状态 → 业务幂等 → 业务事务。新设备请求拒绝 query 参数,JSON 拒绝重复键、非法 UTF-8 和超过 12 层嵌套,普通报文 8 KiB、配置 32 KiB。
限流在昂贵验签之前先按来源执行,通过认证后再按设备执行。Redis 不可用时不能跳过防重放,返回可重试的服务不可用。公开工厂编号和 MAC 只用于定位记录,不构成认证证据。
身份解析器输出内部 device UUID、tenant UUID、工厂编号、协议和密钥版本。现有内部服务继续按 UUID 访问数据库,新协议对外的 device_id 使用工厂编号,platform_device_id 明确返回管理及历史资源所用的 UUID。
### 4.3 C01 与 C02
C01 允许专用 HTTP 校时入口,只返回 `payload_b64` 与 `mac_hex`,签名域为 TIME1;其他业务必须 HTTPS。部署时给 C01 单独路由,不能把整个 /api/v1/device/ 路径开放为明文业务入口。
挑战包含 device_id、key_version 的服务端关联、boot_id、client_nonce、challenge_id、server_time_ms、expires_at_ms;有效期 120 秒。设备校验新 nonce、回显和 RTT 是联调条件。服务器不接受将 C01 响应当成登录凭证。
C02 使用挑战完成已有身份的上线登记,保存固件和 SDK 版本、当前 boot_id。库存登记已经关联内部设备记录,因此此处更新既有记录,不让公开调用创建设备身份。注册时核对型号和生产记录,不以设备自报值覆盖库存型号。
C02 的 challenge 消费、注册结果和幂等提交在同一事务内完成。已成功请求重试先定位原业务结果,同时复核当前绑定代数和权限;挑战已消费不影响读取该成功结果,新 request_id 则须新挑战。
### 4.4 幂等执行器的必要修改
现有 `internal/idempotency/executor.go` 在命中缓存时直接返回 response_body。新协议必须增加事务内 replay guard,并支持调用方控制事务的 ExecuteTx,保证检查当前状态和读取重试结果之间没有换绑竞争。
作用域建议为 tenant/internal_device/contract/method/path/request_id,摘要采用原始 body。保存 24 小时的是短期请求结果;claim_id、activation_id、attempt_id 的业务唯一记录不随缓存过期删除。
固定操作结果与当前状态分别处理:操作未变化且代数仍有效时返回原结果;设备已换绑、停用或权限变化时返回 409/403 并要求查询最新状态,不把旧状态包装成新结果。对只读 poll/status 不缓存过时状态,每次查询使用新的请求号。
handoff token、设备密钥和 DeviceSecret 不明文存进通用 response_body。短时响应恢复采用独立加密存储,绑定有效期和当前权限;普通幂等记录只保存资源引用。
## 5 设备接口修改清单
以下 11 个 POST 路径保留附件的路径命名;字段标注“新增”的属于拟定 1.2 契约变化,不得对 1.1 客户端隐式启用。C01 使用专用校时协议,其余使用新 HMAC。
| 编号和路径 | body 核心字段 | 实施后的行为 |
|---|---|---|
| C01 /api/v1/device/time-challenges | device_id、key_version、boot_id、client_nonce | 返回签名时间及挑战,未知/未放行设备拒绝 |
| C02 /api/v1/device/register | challenge_id、boot_id、hardware_model、hardware_revision、firmware_version、sdk_version | 验证现有身份,返回契约版本及公共 state |
| C03 /api/v1/device/setup-sessions | boot_id、local_setup_id、transport_profile=LOCAL_WEB | 未绑定建立 ENROLL;已绑定建立 RECOVERY,恢复原激活任务 |
| C04 /api/v1/device/handoffs | setup_id、local_setup_id、code_challenge | 仅 ENROLL 签发 90 秒一次性手机入口 |
| C05 /api/v1/device/poll | setup_id、boot_id、known_revision | 当前 state、有效命令和 poll_after_ms;绑定阶段默认 2 秒 |
| C06 /api/v1/device/claim-confirmations | claim_id、challenge、binding_digest、expected_binding_generation、press_event_id | 指定申请确认;事务提交绑定或返回同一有效结果 |
| C07 /api/v1/device/state-acks | device_revision、binding_generation、local_generation、state_hash | 记录设备保存事实,返回 accepted_revision、setup_can_close |
| C08 /api/v1/device/activation-attempts | activation_id、config_version、attempt_id;新增 stage | 只签发 ENGINE_INIT/CONVERSATION_TEST 的设备许可;供应商注册许可由 worker 内部执行 |
| C09 /api/v1/device/activation-results | activation_id、attempt_id、stage、sdk_result、outcome;测试附新增 evidence | 核对同设备尝试和真实会话证据,更新相应阶段;拒绝设备伪报 SERVER_REGISTER |
| C10 /api/v1/device/activation-acks | activation_id、local_generation、config_version;新增 binding_generation、config_digest | 核对当前绑定和配置,记录设备保存运行配置 |
| C11 /api/v1/device/setup-close | setup_id、reason、final_revision | 关闭临时入口和未完成申请;保留已绑定关系及激活任务 |
新增协议版本通过生产记录固定,并在 C02 返回。固件只支持 1.1 时不得把 1.2 当作可忽略的小字段更新;必须完成版本能力校验后再使用。
### 5.1 公共状态示例
以下是拟定响应示例,UUID 为演示值,state_hash 由正式状态计算,不能把示例占位符作为测试向量。
```json
{
"ok": true,
"code": "OK",
"request_id": "f25fcabb-53e4-42c2-ad5b-048775f54414",
"server_time_ms": 1788681600000,
"data": {
"contract_version": "ONBOARDING-1.2-SAAS",
"state": {
"device_id": "KP01-84FCE6123456",
"platform_device_id": "b71dd44c-9a4d-4fdc-a4c3-e6921462c443",
"device_revision": 4,
"binding_generation": 1,
"binding_state": "BOUND",
"activation_state": "RESERVED",
"next_action": "RESUME_ACTIVATION",
"state_hash": "<STATE1计算得到的64位小写hex>"
}
}
}
```
新路径使用附件的 ok/code/data 响应封装,旧 API 保留原封装。不要全局修改 WriteJSON 使旧前端和旧设备同时发生响应变化。
### 5.2 首次接入命令
新增独立的 onboarding 命令队列,复用已有投递和去重经验,但不直接塞入只允许四种旧命令的 device_command 约束。
- CONFIRM_CLAIM:固定 claim_id、挑战、binding_digest、masked_account、expected_binding_generation 和期限。
- APPLY_ACTIVATION:固定 activation_id、config_version、binding_generation、config_digest、config_b64、config_mac 和期限;设备重复收到只同步同版配置。
- UPDATE_STATE:请求同步较新状态,不改变 owner。
- CLOSE_SETUP:请求结束临时设置流程,不等于解绑。
命令按设备、setup 模式、代数和到期时间过滤;取消或换绑后立刻失效。挑战及含 DeviceSecret 的命令载荷加密保存,重复投递可恢复原内容,普通日志仅记录命令 ID 和摘要。
## 6 手机接口和账号修改
### 6.1 七个目标接口
以下字段是完整方案拟定的数据契约,补齐附件未写全的短信及回执部分。新 Web 写接口均使用 HTTPS、当前会话、Origin/CSRF 检查与 X-Request-ID。
| POST 路径 | 请求数据 | 响应及限制 |
|---|---|---|
| /api/v1/web/handoffs/exchange | token、verifier | 设置临时 Cookie,返回 setup_id、脱敏设备信息及当前阶段;不要求预先登录 |
| /api/v1/web/auth/sms/send | phone_e164、purpose=LOGIN | 返回 sms_challenge_id、expires_at_ms、resend_after_ms;不泄露号码是否已有账号 |
| /api/v1/web/auth/sms/verify | sms_challenge_id、code | 原子消费验证码,登录或建立身份映射,旋转 Cookie;保留服务端 setup |
| /api/v1/web/claims | elder_id、expected_binding_generation | 服务端解析使用者所属家庭,返回 claim_id、期限、等待按键状态 |
| /api/v1/web/setup/status | 空对象,使用当前 Cookie | 当前 setup 的绑定、设备保存、激活进度和可恢复动作 |
| /api/v1/web/setup/ack | binding_id、binding_generation、received_revision | 记录页面已看到该绑定;旧绑定的回执不能用于新绑定收尾 |
| /api/v1/web/claims/cancel | claim_id、expected_binding_generation | 仅取消该会话有权管理的 PENDING;已提交时返回当前已绑定状态 |
第一阶段暂不开放 sms/send 和 sms/verify,改由 OIDC 登录接续接口完成身份衔接。完整发布前补短信提供方适配和真实联调,不能用模拟验证码对外发布。
### 6.2 OIDC 接续及账号权限
第一阶段拟新增 `POST /api/v1/web/setup/auth-link`:接收当前已验证的 OIDC Bearer 凭据,同时核对 setup Cookie 和 CSRF,将平台账号与本 setup 关联并旋转会话。它是 OIDC 适配接口,不替代 handoff 兑换。
完整方案增加统一平台会话解析层。OIDC 身份和已验证短信身份均映射到稳定的平台主体,再从 user_account、household_member 获取权限。仅向明确开放的家庭/档案路由增加平台会话鉴权;Cookie 写请求必须检查 CSRF。旧 Bearer 入口继续按现有方式工作。
现有部分服务直接按 issuer/subject 查询账号,需集中到共同的账号访问解析器,或在可信会话适配层使用明确的自有身份命名空间。不得把未经外部提供方签发的用户伪装为其 OIDC subject,也不得允许手机号自动合并另一账号。
设备库存确定目标租户。首次用户只可在允许自助入驻的消费租户中建立普通客户账号及家庭;企业租户没有邀请或授权时拒绝自动加入。注册不授予运维或租户管理能力。手机号绑定到已有 OIDC 账号须经过双方身份验证,不按相同显示名称合并。
用户已有家庭和使用者时直接选择;没有时通过现有家庭及档案服务创建。完成登记后必须再次校验使用者属于同租户且当前账号拥有设备管理能力。
### 6.3 入口页和浏览器会话
新建 `/onboarding/entry`、`/onboarding/setup`、`/onboarding/result` 页面。entry 读取 fragment 中的 token 和 verifier,立即 replaceState 清除地址,然后同源 HTTPS 兑换;禁用第三方脚本、统计和凭据缓存。登录页不访问设备私网 HTTP。
handoff 90 秒、claim 120 秒;setup 默认 15 分钟且绝对不超过 30 分钟。Cookie 建议为 `__Host-onboarding`,Secure、HttpOnly、Path=/、无 Domain,并按 OIDC 顶层回跳验证 SameSite=Lax 的实际行为。
登录成功后旋转 session ID 并保留数据库中的 setup;手机号、验证码、Token 不写 URL 或普通日志。发送验证码默认间隔 60 秒、验证码 5 分钟、单挑战最多 5 次验证,为拟定初值,另设手机号/IP/浏览器/全局发送限额。
兑换响应丢失时,有 Cookie 就查询当前状态;无 Cookie 就从同设备重新取入口,已经消费的 token 不复活。setup 关闭后普通账号会话仍可从设备列表查看进度,避免用户只能通过一次性入口访问已绑定设备。
## 7 绑定提交和所有归属写入口
### 7.1 统一的绑定服务
新增 `internal/devicebinding`,提供接收 pgx.Tx 的归属操作,供新 claim、旧绑定码、运维、正式解绑转移、恢复出厂和隐私删除共用。已有事务由调用方持有,公共服务不能私自另开事务导致部分提交。
新设备的 ConfirmClaim 应完成:
1. 验证设备请求,并按固定顺序锁定设备、setup 和 claim;目标权限行按统一顺序读取和锁定。多设备任务按 UUID 排序取锁。
2. 检查同申请是否已经成功:仅当前有效绑定和代数一致时返回原结果;归属已变化返回 409,要求同步当前状态。
3. 检查 setup 为有效 ENROLL、设备未绑定且未停用、claim 未取消且未过期、当前账号仍有使用者管理权限。
4. 核验 challenge、binding_digest、expected_binding_generation、press_event_id,并确保按键事件没有被另一申请使用。
5. 写入唯一有效绑定,binding_generation 增加一次,消费 claim,更新 onboarding state 和 revision,保存不可变快照。
6. 创建或关联固定激活任务,写入 `device.bound`/`device.activation.requested` outbox,事件去重键含设备与绑定代数;同时记录审计。
7. 提交后返回 BOUND。设备落盘 ACK 和供应商调用均发生在本事务之后。
取消和确认采用相同锁顺序。已绑定后再取消,只返回“绑定已完成”,不删除 owner。不同账号同时申请时返回 CLAIM_BUSY,不泄露另一个账号的完整信息。
### 7.2 其他写入口的处理
| 当前入口 | 新设备的规则 | 旧设备的兼容处理 |
|---|---|---|
| /api/v1/device-claims 绑定码 | 拒绝直接建归属,返回 DEVICE_CONFIRMATION_REQUIRED 和受控流程说明 | 保留原流程,但重试复核有效绑定 |
| 运维 Create/Patch 的预分配 | 对新模式禁止直接指定家庭/使用者;普通运维不能模拟设备按键 | 原设备按现有权限执行,并统一记录代数变化 |
| 运维删除/吊销 | 撤销归属及旧会话/命令,记录不可用状态;不能把设备重新开放给任意账号 | 保持原停用业务,复用新失效清理 |
| binding-change | 新模式走原账号授权解绑,目标账号重新申请并由设备确认 | 旧模式保持现有变更前检查,写入代数与审计 |
| /device/v3/factory-reset | 新模式只执行已获原账号或受控售后授权的完整重置;无授权不能解绑 | 保留旧产品行为并明确其语义;不可作为普通重配网接口 |
| 隐私删除处理器 | 解除对应使用者设备关系、递增代数、撤销旧流程与个性化配置 | 同样接入统一归属服务,避免绕过代数更新 |
最后一项来自当前 `internal/privacy/delete_processor.go` 的直接解绑写入,实施时必须覆盖。不能只修改可见绑定页面。
新设备普通重配网保留身份、owner 和供应商身份;需要云端记录时仅报告网络操作结果。受控售后解绑的申请、执行账号、理由和证据独立审计,不纳入首次绑定的快捷入口。
## 8 状态回执和收尾
binding_state 为 UNBOUND、CLAIM_PENDING、BOUND、TRANSFER_PENDING;activation_state 为 NOT_STARTED、RESERVED、CONTACT_POSSIBLE、REGISTERED_OBSERVED、READY、RECONCILE_REQUIRED、BLOCKED。现有 lifecycle 字段保留作兼容投影,由同一状态服务更新,避免多个模块各写一套最终事实。
device_revision 只随新协议公开状态变化;last_seen 更新不必增加该版本。STATE1 使用 device_id、device_revision、binding_generation、binding_state、activation_state 五个字段,前置 STATE1 后共六行;platform_device_id 等新增展示字段不加入原 hash 公式。
每次版本变更保存状态快照。C07 收到同版本同 hash 返回成功;过期代数或未知 hash 返回 409 并提示同步。local_generation 在设备和有效启动上下文中验证;若设备存储发生恢复,不以降低云端代数的方式迁就本地状态。
绑定与激活可继续并行:C07 ACK 只表示保存当前状态,Web ACK 表示看到了当前绑定。收尾以同一 binding_id/generation 的有效设备保存证据和页面接收证据为依据;页面收到较早 revision 不得被误当成看到了另一次绑定。
设备和页面都确认后等待 3 秒关闭;只有设备确认时最多 30 秒;AI 激活不延长该时间。C11 失败时设备可按本地安全上限关闭 AP,随后补报;SaaS 保存关闭事实但不删除原激活任务。
setup 过期或关闭后不再签发 handoff 和新 claim;认证设备可通过 C03 建 RECOVERY 上下文,再经 C05 获取自身状态、原任务和必要配置。恢复入口不授予新的账号所有权。
## 9 激活模块改造
### 9.1 将现有同步 Provision 拆成持久化阶段
当前 `internal/hardwareprovision/service.go` 在数据库事务持锁期间调用供应商注册。改造后分为:
1. 短事务内固定 provider_device_identity 和 activation_id,核验当前绑定与权益,创建 SERVER_REGISTER attempt,持久化 CONTACT_POSSIBLE,提交。
2. worker 在事务外调用供应商,携带稳定 device_name;不持有数据库长事务。
3. 新短事务保存明确结果和证据,加密 DeviceSecret,更新状态并发布 APPLY_ACTIVATION 命令。
worker 取到 outbox 重试事件时先检查业务任务状态。已有 CONTACT_POSSIBLE 但没有明确结果,进入对账;租约过期不能直接重新调用供应商。fence_version 用于拒绝旧 worker 覆盖新状态,但不把本地租约当成远端请求已取消的证据。
### 9.2 成功与未知结果
| 结果 | 处理 |
|---|---|
| 明确未发出请求的本地前置失败 | 保存错误;修复后可按策略创建新 attempt |
| 明确注册成功并取得合法凭据 | 保存供应商身份与 REGISTERED_OBSERVED;不直接写 READY |
| 明确授权不足/过期 | BLOCKED,显示服务待处理;保留绑定 |
| 注册请求超时、崩溃或回包未知 | RECONCILE_REQUIRED;保留原身份、任务和可能占用的权益 |
| 设备未保存配置 | 保持已知供应商结果,重发相同有效配置或生成新的配置版本 |
| 设备引擎初始化失败 | 记录 DEVICE/ENGINE_INIT 尝试,保留已知注册结果;不凭此释放供应商权益 |
| 已验证首通测试成功 | 满足证据、权限和保存条件后进入 READY |
对账通过经验证的供应商查询能力或受控人工证据完成。若供应商不提供可靠查询,不虚构自动对账 API;保持待处理,由运维基于供应商记录执行有审计的结论操作。
供应商身份按物理设备固定。换绑定时更新个性化配置版本并使旧代数失效,不重新分配 provider device_name 或盲目再注册。旧 attempt 的晚到结果可记录物理设备供应商事实,但不能恢复上一位用户的配置或推进新用户的 READY。
### 9.3 设备配置和 SDK 兼容验证
新的配置包含 contract_version、device_id、activation_id、config_version、binding_generation、expires_at_ms、config_digest、instance_id、product_key、device_name、device_secret、bot_id。通过设备 HTTPS 通道交付,并以 CFG1 HMAC 验证完整性。
ProductSecret 只在 SaaS worker 的供应商适配器中使用。配置不放进 Web status、普通 outbox payload 或明文幂等缓存。outbox 只记录引用,设备拉取时重新核对代数、任务和有效期。
必须验证当前 SDK 能使用已注册 DeviceSecret,且不会无条件触发另一轮动态注册。若该能力不成立,阻断此激活路线的发布并回到协议设计,不在代码里静默双重注册。此项只影响真实激活发布,不阻止先完成认证与绑定。
### 9.4 首通测试与日常通话
不能让“只有 READY 才能创建任何会话”和“必须完成首通测试才能 READY”互相阻塞。新设备达到 REGISTERED_OBSERVED、有效绑定、运行配置已 ACK 且用户明确发起测试后,允许携带 C08 的短时测试许可创建专用测试会话。
拟扩展现有 `/device/v3/agent-sessions`:仅新协议支持 intent=activation_test、activation_id、attempt_id、permit_token;正常设备通话仍检查当前绑定、配置、权限与服务状态。测试路径有时长/次数限制,不构成绕过普通服务权限的入口。
C09 的测试 evidence 至少关联 conversation_id、首次有效下行音频观测、音频检查结果、SDK/固件版本和结束状态;服务端与该次会话记录核对。只收到 session_started 或媒体连接事件不能写 READY。挂机准备不自动采集环境音,测试必须由用户明确触发。
### 9.5 新旧设备如何调用现有会话接口
新增统一 DevicePrincipal 适配:先根据已登记的设备身份与 enrollment_mode 选择唯一允许的认证方案,再交给现有内部服务。新设备在无 query 的现有 v3 会话/事件及 v2 上报路径可使用 DEVREQ1,旧设备继续旧五行签名;不根据 User-Agent 判断协议,不在验签失败后回退另一算法。
含 query 的旧 GET 接口不供新协议复用,新设备使用 C02/C05 等新 POST 路径。若以后需要 OTA 下载、媒体查询等功能,必须另列适配清单或提供无 query 的新入口;不能宣称本次首次接入方案已经覆盖所有旧设备能力。
现有 `/device/v3/hardware-agent-credentials:provision` 对旧模式保持原约定;新模式只读取已有已确认凭据,未准备完成返回当前任务状态或引导 C05,不能再次触发同步注册。凭据读取同样必须检查当前有效绑定。
## 10 前端和代码修改位置
| 模块或路径 | 修改内容 |
|---|---|
| internal/onboardingauth 新增 | DEVREQ1、TIME1、版本密钥解析、限长校验和新身份上下文 |
| internal/onboarding 新增 | C01 至 C07、C11、setup/handoff、状态快照和命令投递 |
| internal/devicebinding 新增 | 统一的事务内绑定、解绑、代数推进和旧上下文失效 |
| internal/activationjob 新增 | 固定任务、attempt、许可、权益、结果和对账 |
| internal/websession / internal/smsauth 新增 | OIDC 接续、短信验证、平台会话和 CSRF |
| internal/httpapi/router.go 及新增 handlers | 按契约挂载新路由、独立响应封装、显式鉴权分组 |
| internal/opsdevice/service.go | 工厂库存入口、凭据交付及所有归属写入的模式限制 |
| internal/saas/device_claim.go、device_binding_service.go | 旧入口新设备门禁、共同绑定事务和权限复核 |
| internal/devicereset/service.go | 区分普通重配网和获授权完整重置,撤销旧流程 |
| internal/privacy/delete_processor.go | 隐私解绑接入代数和状态失效流程 |
| internal/hardwareprovision/service.go | 从同步注册拆到 worker,保留已确认凭据读取和加密能力 |
| internal/idempotency/executor.go | ExecuteTx、事务内缓存命中权限/代数检查、敏感响应引用 |
| internal/outbox 和 worker 注册处 | 绑定/激活事件处理;网络重试与外部未知结果分开 |
| internal/identity、internal/app、internal/config | 账号解析统一、服务装配、新配置和按设备开关 |
| web/src/pages 下新增 Onboarding 页面 | 入口兑换、登录接续、档案选择、按键等待、保存和激活进度 |
| web/src/pages/SaaSWorkspace.tsx | 新设备不展示直接绑定码流程;正常账号设备列表显示分阶段状态 |
| web/src/pages/OpsDeviceWorkspace.tsx | 工厂编号/MAC、绑定历史、ACK、激活异常、受控对账操作 |
| web/src/api/client.ts、schema.d.ts | 从更新后的契约同步请求与返回类型 |
| openapi/openapi.yaml 和兼容说明 | 增加新契约及身份编码;保留 v2/v3 旧契约和旧测试 |
| internal/contractgovernance | 新增 ONBOARDING 契约检查、敏感字段边界和接口覆盖检查 |
前台文案按实际事实显示:正在认证、等待按键、已绑定但设备保存中、AI 服务准备中、待处理、可用。设备重连和手动刷新复用原 setup/claim/activation,不自动发新验证码或再建任务。
运维页面新增读取接口建议:`GET /api/v1/ops/devices/{id}/onboarding`、`GET /api/v1/ops/devices/{id}/activation-jobs`。受控对账建议 `POST /api/v1/ops/activation-jobs/{id}/reconciliations`,提交 conclusion、evidence_ref、reason、expected_version;权限限制到专门能力,不能用一个普通“重试”按钮解除未知结果。
## 11 错误与恢复规则
| 错误或状态 | HTTP | 客户端下一步 |
|---|---|---|
| DEVICE_AUTHENTICATION_FAILED | 401 | 停止敏感请求;检查密钥、时钟或受控恢复 |
| DEVICE_ACCESS_DENIED | 403 | 设备停用、租户或账号权限问题,显示联系支持 |
| REQUEST_BODY_CONFLICT | 409 | 同请求号不同内容,修复调用方 |
| DEVICE_CONFIRMATION_REQUIRED | 409 | 进入按键绑定流程,不能改用旧接口绕过 |
| CLAIM_BUSY | 409 | 等当前申请结束或由有权会话取消 |
| CLAIM_EXPIRED / HANDOFF_EXPIRED | 410 | 重新申请短期对象,保持设备和既有绑定 |
| BINDING_GENERATION_CONFLICT / STATE_MISMATCH | 409 | 查询当前状态,丢弃旧配置或旧确认 |
| CONTRACT_UNSUPPORTED | 426 | 使用双方支持的固件和契约,不猜测枚举含义 |
| RATE_LIMITED | 429 | 按 retry_after_ms 退避 |
| ACTIVATION_RECONCILE_REQUIRED | 200 状态查询或 409 新调用申请 | 展示待对账;禁止自动再注册 |
| DEPENDENCY_UNAVAILABLE | 503 | 保留业务 ID、原 body,更新时间和 nonce 后重试 |
错误响应携带 request_id、retry_after_ms、recovery_action 和可公开的错误原因,默认退避 2、5、15、30 秒,最大 60 秒并加抖动。配置不得通过错误详情泄露。
## 12 分阶段开发和交付物
每阶段完成其必需测试后进入下一阶段,工期由团队人数和供应商适配验证后排定,不用未经评估的天数替代验收条件。
| 阶段 | 工作项 | 可验收的交付物 | 依赖 |
|---|---|---|---|
| P0 兼容保护与契约 | 固定 ID/密钥/代数/租户规则;新增数据结构;所有旧归属入口加入模式门禁;OpenAPI 草案 | 数据迁移演练和旧接口回归通过,新模式尚未对产线开放 | 无 |
| P1 生产登记与认证 | 库存去重、凭据交付/放行、C01/C02、新 HMAC、幂等 guard | 模拟设备可受控登记并重复认证;测试向量与旧设备均通过 | P0 |
| P2 完整绑定流程 | C03 至 C07/C11、handoff、OIDC 接续、申请和确认事务、ACK、收尾页面 | 两个账号并发、回包丢失和断电恢复均不重复绑定;前台显示已绑定待开通 | P1 |
| P3 独立激活 | outbox worker、固定供应商身份、尝试与对账、C08 至 C10、配置 ACK、首通测试 | 明确成功与未知结果均可恢复,只有真实验收后 READY | P2;SDK 凭据模式及供应商行为验证 |
| P4 完整手机入口与发布 | 短信/账号注册、租户策略、正常列表恢复、运维对账、灰度观测 | 文档目标手机流程完成,真实短信和设备联调通过后扩大批次 | P2;P3 通过后才发布完整 AI 可用流程 |
建议按以上阶段拆 PR。P0/P1 可以独立评审,P2 可先用模拟激活验收,不等待真实火山调用。短信适配可在接口固定后与 P3 由团队独立实施,但完整验收必须等真实提供方接入。
完整交付应包含:迁移脚本、API 契约、后端与页面修改、设备调用示例和 HMAC 向量、故障测试记录、灰度及恢复手册、供应商模式验证结论。不能只以新增路由都返回 200 作为完成标准。
## 13 迁移 灰度和回退
1. 发布 P0 兼容保护版本,所有新开关默认关闭,旧设备正常回归。数据库只增加结构,保留原列和旧凭据。
2. 回填旧设备 legacy 标记、既有供应商身份和可验证的绑定历史。不能把旧 ACTIVE 自动当成新 READY;有真实证据才迁入 READY,否则保留旧模式直到完成迁移验收。
3. 在测试租户、单个受控工厂批次启用 enrollment_mode=physical_confirm。已有设备迁移必须核对实际 MAC、身份映射、密钥交付和固件能力,不能靠数据库批量切标记完成。
4. 分别控制新建入口、认领、激活外部执行、恢复查询四类开关。暂停新建不关闭已提交绑定的查询/ACK,不让设备因发布回退而失去恢复原任务的路径。
5. 灰度检查注册成功率、handoff 兑换率、确认成功率、状态保存延迟、激活对账积压、nonce 拒绝和 409 分类;指标只记录关联 ID,不包含秘密。
6. 回退到至少包含 P0 保护和恢复能力的版本;不执行删除新表的 down migration,不把 physical_confirm 设备改回 legacy 来绕过故障。
7. 停止新的供应商调用后,保留 CONTACT_POSSIBLE/RECONCILE_REQUIRED,等待已有调用和对账处理。不得回收可能已经占用的权益。
建议配置分别表达入组范围、入口启用、激活 worker 启用、恢复读取保留和短信发送启用。前端按钮隐藏只控制展示,所有权限与模式门禁在服务端执行。
## 14 测试和验收清单
| 测试类别 | 必测情形 | 通过标准 |
|---|---|---|
| 生产身份 | 同 MAC 重试、登记回包丢失、型号/租户冲突、返修、密钥轮换中断 | 同一设备不变;冲突不覆盖;可恢复原交付任务 |
| 新旧鉴权 | 附件 H 向量、旧 UTF-8 密钥、原始 JSON 空格变化、版本错误、query、超限 JSON | 各自按契约通过;错误不回退另一算法 |
| 时间和重放 | 首次错误时钟、旧挑战、同 nonce、不同 boot_id、Redis 中断 | 不接受旧挑战或跳过防重放,不关闭业务 TLS |
| 幂等和权限 | 同请求同 body、同请求不同 body、缓存命中前停用/换绑、24 小时后旧 claim | 有效操作不重复;旧权限和旧状态不可复活 |
| Handoff | 双兑换、过期、verifier 错误、Cookie 回包丢失、OIDC 回跳 | 一次兑换且可恢复;不把入口当所有权 |
| 短信与租户 | 重发、穷举、供应商超时、注册重试、已有 OIDC 账号、企业租户无邀请 | 不重复建账号、不自动越权、不错误合并身份 |
| 绑定竞争 | 双账号认领、确认与取消并发、权限撤销、申请过期后的按键 | 唯一有效归属;无当前设备确认不能提交 |
| 状态保存 | 提交后丢包、设备 ACK 重复或旧版本、页面 ACK 旧绑定、保存中断电 | 云端绑定不回滚;只承认对应代数和状态证据 |
| 旧入口覆盖 | 绑定码、运维预分配、解绑/转移、恢复出厂、隐私删除 | 新模式无确认绕过;代数和旧上下文一致失效 |
| 激活 worker | 外部调用前崩溃、调用中崩溃、成功后数据库失败、重复 outbox、租约过期 | 同一可能已调用的任务不被自动再次执行 |
| 供应商与权益 | 额度不足、已注册查询、未知结果、人工对账、换绑期间晚到结果 | 保留身份和可能占用权益,证据可追踪 |
| 首通及运行时 | DeviceSecret 模式、测试许可重放、媒体已连无首音频、正常首通、立即挂机 | 不重复注册;无真实测试证据不 READY;待机不采音 |
| 浏览器恢复 | 手机切网、门户关闭、普通浏览器重开、登录超时、设备已绑定后再进旧入口 | 恢复当前状态,不要求重做已成功步骤 |
| 发布回退 | 新旧设备同时在线、停用新建入口、P0 版本回退、恢复服务继续 | 旧设备可用,新设备既有绑定和任务不丢失 |
后端使用 PostgreSQL/Redis 集成测试验证并发与事务,不用 mock 替代唯一约束和进程中断测试。前端使用现有 Playwright 流程验证手机和桌面页面。真实供应商、短信和设备联调记录单列,静态代码或模拟服务通过不等于实机通过。
## 15 关键代码依据
- [运维建档与预分配](E:/Codex/story/platform/internal/opsdevice/service.go:244)
- [当前设备签名验证](E:/Codex/story/platform/internal/deviceauth/verifier.go:69)
- [现有幂等响应直接重放](E:/Codex/story/platform/internal/idempotency/executor.go:145)
- [当前绑定码提交](E:/Codex/story/platform/internal/saas/device_claim.go:27)
- [当前解绑和转移](E:/Codex/story/platform/internal/saas/device_binding_service.go:67)
- [恢复出厂的解绑写入](E:/Codex/story/platform/internal/devicereset/service.go:168)
- [隐私删除中的解绑写入](E:/Codex/story/platform/internal/privacy/delete_processor.go:302)
- [供应商注册当前执行方式](E:/Codex/story/platform/internal/hardwareprovision/service.go:127)
- [可复用的事务 outbox](E:/Codex/story/platform/internal/outbox/store.go:40)
- [现有 OIDC 入口](E:/Codex/story/platform/web/src/auth/oidc.ts:6)
- [当前账号与租户映射](E:/Codex/story/platform/internal/identity/repository.go:71)
- [原始差异分析](E:/Codex/story/platform/docs/首次配网设备认证与绑定_SaaS支撑功能分析.md)
@@ -0,0 +1,278 @@
# 首次配网设备认证与绑定 SaaS 支撑功能分析
分析日期:2026 年 9 月 11 日
依据:附件《AI电话 首次配网 注册与激活 完整流程与详细设计》,整合日期 2026 年 9 月 8 日,公共契约 ONBOARDING-1.1;以及当前工作区 SaaS 的路由、服务、数据库迁移和前端代码。
结论:现有平台具备设备建档、独立密钥、设备鉴权、家庭和使用者权限、绑定关系、设备配置与命令、供应商凭据和审计等基础,但尚未实现附件要求的完整首次接入流程。主要新增工作是可信首次校时、设备注册协议、云端设置会话、手机入口交接、登录后按键确认绑定,以及绑定状态保存回执。激活任务也需要独立的持久化状态和异常恢复机制。
本分析只列 SaaS 要承担的工作及其设备协议边界。Wi-Fi 扫描、密码保存、AP、NAPT、GPIO 和设备存储的具体实现由设备承担。SaaS 不接收家庭 Wi-Fi 密码。本文区分附件要求、当前实现和实施建议;附件中的设计不代表这些功能已经上线。
核对方式:读取正文、附件 A 至 K 和 5 张流程图;静态检查实际路由和实现。附件列出的 18 个云端接口均未在当前 router 中以相同方法和路径注册,但部分能力有现有服务可复用。附件 H 的 HMAC 示例和三组热点名称向量已离线复算一致。没有进行真实设备、短信服务或供应商联调。
## 1 SaaS 应负责的完整业务顺序
1. 受控生产流程登记设备身份和密钥,建立唯一库存记录。
2. 设备联网后请求可信时间,再携带设备认证信息完成注册和状态恢复。
3. SaaS 为未绑定设备创建设置会话,签发短时手机交接入口。
4. 手机兑换入口并登录账号;服务端保存当前设备上下文,提供有权限的使用者列表。
5. 手机选择使用者并创建绑定申请,此时设备尚未归属于该账号。
6. SaaS 向设备下发当前申请的确认挑战;用户按键后,设备回传对应申请的确认。
7. SaaS 在一次事务内提交绑定、消费申请、增加绑定代数、更新状态版本、写入激活待办。
8. 设备保存绑定并回执;手机读取结果并回执。设置入口按收尾条件关闭。
9. AI 激活任务独立继续;失败、页面关闭或设置会话过期,都不撤销已提交绑定。
必须分别展示三个结果:设备身份已认证、账号已绑定、AI 服务是否可用。SaaS 收到 HTTP 请求成功、设备本地保存成功、真实首音频验证成功,也分别记录。
## 2 功能清单与现有实现对照
“可复用”表示存在相应基础逻辑,不表示已经符合 ONBOARDING-1.1 的报文和全部业务约束。
| SaaS 功能 | 附件要求和用途 | 当前实现 | 需要的工作 |
|---|---|---|---|
| 生产身份与库存 | 按完整设备 ID 和出厂 MAC 唯一登记;返修恢复原记录;密钥轮换留审计 | 运维接口可建档、生成随机 UUID 和密钥、轮换凭据 | 增加生产身份映射、MAC 唯一约束和幂等生产登记;区分库存与首次上线 |
| 设备认证 | 按设备和密钥版本验证 DEVREQ1 HMAC,检查时间、nonce、停用和吊销 | 已有 HMAC、时间窗口、防重放、加密密钥存储、状态限制 | 新增 ONBOARDING-1.1 认证适配;现有签名格式不能直接兼容 |
| 首次可信校时 | 用单设备密钥签名时间挑战,设备验签后再严格使用 HTTPS | bootstrap 返回服务器时间,但须先通过现有认证 | 新增 C01;不能把现有 bootstrap 当作无可信时间时的校时入口 |
| 设备注册与恢复 | 只接受已登记的生产身份;重复注册返回原设备、绑定和激活状态 | 设备由运维创建;bootstrap 更新在线报告并返回绑定 | 新增 C02、挑战消费、业务幂等及完整公共状态 |
| 云端设置会话 | 关联设备、启动、本地会话、期限和流程阶段 | 未发现 setup_session 完整实现 | 新增 C03 和设置会话;已绑定设备只进入恢复模式 |
| 手机入口交接 | handoff 与 verifier 摘要匹配,短时、一次性兑换,生成浏览器会话 | 未发现此交接流程 | 新增 C04、Web exchange 和 HTTPS 入口页 |
| 账号登录与使用者权限 | 短信登录或注册、保留 setup、校验有权管理的老人档案 | 已有 OIDC 登录、平台账号映射、家庭成员权限、使用者档案 | 若严格遵循附件需补短信入口与注册映射;也可复用 OIDC,但须修改双方认可的登录契约 |
| 绑定申请 | 登录后固定设备、账号、使用者、绑定代数和挑战;每设备一条有效申请 | 一次性绑定码表存在,但不绑定某次设备按键挑战 | 新增待确认申请;不能直接把现有 claim_code 当作新 claim |
| 设备确认后提交绑定 | 核验 claim、challenge、digest、按键事件和当前权限后原子提交 | 当前 POST /api/v1/device-claims 提交绑定码后直接写绑定 | 新增 C06;保留并扩展行锁、有效绑定唯一约束与审计 |
| 状态版本和保存回执 | 独立 binding_generation、device_revision、local_generation、state_hash;记录设备与页面 ACK | 已有 device.version、activation_revision、配置 revision | 新增明确的绑定代数和状态快照;现有版本不能直接互相替代 |
| 流程命令与进度查询 | CONFIRM_CLAIM、APPLY_ACTIVATION、UPDATE_STATE、CLOSE_SETUP | 已有配置、重启、诊断、OTA 命令 | 增加流程命令及权限、过期、去重规则;通用命令 ACK 不等于绑定确认 |
| 独立激活任务 | 固定设备供应商身份与任务;保存调用许可、尝试结果、未知状态和对账证据 | 已有平台代注册、供应商凭据加密保存和复用 | 新增任务和尝试记录;统一平台代注册与设备 SDK 注册的路线 |
| 中断恢复与设置收尾 | 页面关闭、断电、回包丢失后按原任务恢复;关闭 setup 不解除归属 | 有一般设备查询、解绑、重置和部分请求幂等基础 | 新增 C07/C10/C11、Web status/ack/cancel 及完整恢复规则 |
| 运维处理和验收 | 查阶段、绑定、ACK、激活和失败原因,处理停用、返修、对账 | 设备运维、状态迁移、审计和通用 outbox 已有 | 增加首次接入诊断视图和激活对账;将绑定事件接入 outbox |
## 3 认证和生产登记的具体要求
### 3.1 区分生产登记与设备首次注册
上一轮提取的 `POST /api/v1/ops/devices` 是运维建档入口。附件的 `POST /api/v1/device/register` 是设备证明已有身份并报告上线的入口。两者调用者、鉴权和数据用途不同,不能替换使用。
生产侧至少保存:设备固定编号、出厂 MAC、型号、硬件版本、凭据版本、受控密钥引用、生产及返修记录。设备注册请求不能仅凭公开 MAC 获取新密钥,也不能让请求方自行指定账号归属。
现有 Create 每次生成新 UUID 和随机 serial_digest,没有按输入 MAC 重复登记的能力。建议保留数据库内部 UUID,新增唯一的工厂编号与 MAC 映射,由新协议在边界解析 `KP01-84FCE6123456` 一类编号;无需为匹配外部编号直接重写全部内部外键。
现有 FACTORY 状态不允许通过通用设备认证。若生产登记先进入该状态,还需明确出厂放行到可认证状态的流程,或为新库存认证设置单独的准入规则,不能让新设备被自己的初始状态永久挡住。
### 3.2 HMAC 契约存在实质差异
| 项目 | 附件 ONBOARDING-1.1 | 当前平台 |
|---|---|---|
| 设备编号 | KP01- 加完整 12 位大写 MAC | UUID |
| HMAC 密钥字节 | 原始 32 字节 device_key | 当前运维 Create 返回 64 位 hex 字符串,并把该字符串的 UTF-8 字节作为 HMAC 密钥 |
| 时间字段 | X-Timestamp-Ms,Unix 毫秒 | X-Timestamp,RFC3339 |
| 密钥版本 | X-Key-Version,参与签名和密钥选择 | 密钥库有版本,但当前请求签名不包含版本头 |
| 业务请求号 | X-Request-ID,参与签名和幂等 | 现有五行签名不包含业务请求号;部分业务另用 Idempotency-Key |
| 签名正文 | DEVREQ1 开头的九行,包含设备 ID、版本、请求号;路径不含 query | 方法、包含 query 的 RequestURI、时间、nonce、body 哈希,共五行 |
| 签名传输 | Authorization: Device-HMAC-SHA256 加小写 hex | X-Signature,Base64url 无填充 |
| 时间容差 | ±120 秒 | 默认 ±5 分钟,可配置 |
| nonce | 16 随机字节的 Base64url,按设备和版本保存至少 5 分钟 | 接受 16 至 128 字符;当前按设备和 nonce 防重放 |
Header 名称的大小写不是差异重点;签名所包含的内容、编码、密钥字节和时间含义才是兼容性问题。
建议以独立协议适配层支持新路径,复用密钥加密存储和设备状态查询。旧设备继续使用旧契约,避免在同一入口中静默尝试不同签名算法。密钥迁移需要显式标记编码与版本,不能把当前返回的字符串自动解码后用于旧接口。
### 3.3 首次校时与注册提交
C01 接收公开 device_id、key_version、boot_id、client_nonce,返回只包含时间及挑战的签名响应。未知设备通用拒绝。挑战 120 秒有效,签名域为 `TIME1`,不签发账号、绑定或供应商凭据。
C02 验证设备请求后,原子消费与当前设备、启动关联的 challenge_id,并提交注册结果。同一 request_id、同一原始 body 的已成功重试应返回匹配结果;不同 body 返回冲突。已消费挑战不能阻止读取同一已提交结果,但不能授权一次新注册操作。
## 4 手机登录交接需要新增的功能
SaaS 必须维护独立的 setup 上下文,不能只把 device_id 放入登录页 URL。一次 setup 关联设备、boot_id、本地 local_setup_id、模式、期限和当前阶段;默认 15 分钟,绝对上限 30 分钟。
handoff 默认 90 秒有效,数据库保存 token 哈希,并绑定 setup 与 code_challenge。兑换时核对 verifier 摘要、过期状态和一次性消费状态,再建立浏览器会话。它只证明进入了某次设备流程,不授予设备所有权。
HTTPS 入口页从 URL fragment 读取 token 与 verifier,立即清理地址,再同源 POST 兑换。入口固定白名单、禁止任意跳转,页面不加载第三方统计脚本,凭据响应禁止缓存。SaaS HTTPS 页面不反向请求设备的本地 HTTP 接口。
登录前后都需保留服务端 setup;登录成功后更新会话身份并防止会话固定。按附件实现 Cookie 时,应配置 Secure、HttpOnly 和适合该跳转链路的 SameSite,并执行 Origin/CSRF 检查。短信需按手机号、IP、浏览器流程限流,约束验证码期限和尝试次数。
现有 OIDC 登录和家庭权限可以作为账号基础,但 OIDC 自带的 PKCE 不等于附件中的设备 handoff。若继续复用 OIDC,仍要实现“设备流程交接”和“登录后继续原 setup”。
新用户还需要能创建或进入家庭、创建或选择使用者。现有家庭与档案接口可复用:`POST /api/v1/households`、`GET/POST /api/v1/households/{id}/subjects`。附件没有完整规定账号首次加入租户和家庭的策略,这部分必须补入实施设计。
## 5 绑定事务与状态同步
### 5.1 一次绑定申请固定哪些数据
| 数据 | 为什么需要 |
|---|---|
| device_id、setup_id | 确定当前设备和操作流程 |
| 登录账号、tenant_id、household_id、subject_id | 确定归属范围和使用者;账号从登录态取得 |
| claim_id | 为一次明确申请提供长期业务去重标识 |
| challenge、expires_at | 限定本次物理确认窗口,默认 120 秒 |
| binding_digest | 固定被确认的账号和使用者内容;目标变化就重建申请 |
| expected_binding_generation | 防止旧申请在设备已经换绑后继续生效 |
| press_event_id | 防止旧按键事件重复确认另一笔操作 |
| 状态、取消与消费时间、提交结果引用 | 处理取消、确认、超时和网络重试竞争 |
服务端检查的是经过设备认证的“指定申请确认”,不是相信浏览器上传 `button_pressed=true`。配网前的按键授权也不能作为登录后绑定申请的确认。
### 5.2 在一次事务中完成绑定
建议事务顺序:锁定设备及申请 → 识别同申请的已提交结果 → 核验当前权限、代数和未过期挑战 → 检查唯一有效归属 → 写入绑定 → 绑定代数增加一次 → 消费 claim → 更新 device_revision → 写入激活 outbox 事件和审计 → 提交。
复用当前 device_binding 的有效记录唯一索引和行锁,同时为待确认申请增加唯一约束。取消与确认并发时以事务提交结果为准:取消未完成申请不能删除已提交绑定。
现有绑定码逻辑可以保留为独立入口,但不能绕过新协议的按键确认。新模式需明确哪些设备允许使用旧绑定入口,以及运维强制分配归属是否属于受控例外,否则只增加一个安全入口并不能保证所有绑定都经过同样规则。
重试判断不仅要比较原目标,还要确认绑定仍是当前有效归属。当前旧 claim 的已使用分支会读取原 used_binding_id;新流程必须补上绑定代数和当前状态检查,避免设备已解绑或换绑后再返回旧状态作为可用配置。
### 5.3 两种版本与两种回执
`binding_generation` 只在归属发生变化时递增;`device_revision` 在对设备公开的业务状态变化时递增。`local_generation` 是设备存储版本,由设备报告。普通网络更新不能增加绑定代数,普通配置 revision 也不能冒充绑定代数。
C07 验证 device_revision、binding_generation、local_generation 和 state_hash,记录设备已保存哪一版状态。Web ack 记录手机已看到哪次绑定结果。二者均不改变 owner;设备保存失败也不能回滚服务端绑定。
前台应分别显示“待按键确认”“账号已绑定,设备保存中”“账号已绑定,AI 服务准备中”“AI 服务待处理”“AI 服务可用”。轮询超时后仍能从账号设备列表恢复查看。
## 6 激活支撑与现有路线差异
### 6.1 绑定与激活分别提交
附件要求:一台设备一个当前 activation_job,固定 activation_id 和供应商 device_name;每次实际调用有 attempt_id。绑定事务只写待办,后台再处理外部服务。现有通用 outbox 可复用,但当前 ClaimDevice 并未写入这一类激活待办。
至少需要以下业务状态:NOT_STARTED、RESERVED、CONTACT_POSSIBLE、REGISTERED_OBSERVED、READY、RECONCILE_REQUIRED、BLOCKED。绑定状态则单独使用 UNBOUND、CLAIM_PENDING、BOUND、TRANSFER_PENDING。
当前 FACTORY/CLAIMABLE/BOUND/PROVISIONING/ACTIVE/LOST/REVOKED 是另一组设备生命周期状态。不能仅靠一张枚举映射表就认定两套状态相同,特别是当前 ACTIVE 并没有附件要求的真实首音频验收含义。
### 6.2 外部结果未知时的恢复
外部调用前持久化“可能已调用”的记录;记录许可原始有效期和调用尝试。超时、断电、回包丢失进入 RECONCILE_REQUIRED,禁止自动换 device_name、释放可能占用的名额或并行再次激活。结果成功也应区分 SDK 注册成功和真实通话验证成功。
现有 Provision 在数据库事务和锁持有期间调用 registrar.Register,然后存储凭据并提交。如果供应商已处理但回包丢失,事务可能回滚,尚无附件所需的持久化未知结果和对账任务。确定性的 device_name 与已保存凭据复用可以减少重复身份,但不能替代完整的外部调用结果恢复。
### 6.3 实施前需要统一供应商凭据路线
附件第 8 章和 E.2 描述向设备下发 ProductSecret,由设备执行动态注册。当前代码把 ProductSecret 保留在 SaaS,由平台注册后只向设备下发 DeviceSecret。这是执行位置与凭据边界的差异,不能用增加几个返回字段掩盖。
实施建议:优先在现有平台代注册方案上补持久化任务、尝试记录和对账,并同步修订附件中的激活契约。若最终明确选择设备执行注册,则按该路线重新设计调用许可、配置封装和结果回执。两种路线的供应商行为都需要针对实际 SDK/接口版本验证,本文不据文档宣称其已通过实机验证。
代码中还有 License 内容导入、分配和 ACK 相关模块;当前主 router 未接入相应 License ACK/分配闭环,不能视为已有 ONBOARDING-1.1 的授权对账能力。附件采用权益或名额管理,也没有定义向设备交付 License 文件。应先统一授权模型,再确定复用范围。
## 7 附件明确要求的云端接口
下列 18 条均为附件规定的目标接口,当前没有同路径路由。生产登记、运维管理以及已有家庭/使用者管理接口不包含在这个数量中。
### 7.1 设备调用 SaaS
除 C01 使用专用时间签名协议外,均按附件 B 使用设备 HMAC 和 HTTPS。表中字段为 body,设备 ID、密钥版本和请求号等还须按契约放入认证头。
| 编号 | POST 路径 | 核心输入 | SaaS 负责输出或提交 |
|---|---|---|---|
| C01 | /api/v1/device/time-challenges | device_id、key_version、boot_id、client_nonce | 签名时间与短时 challenge_id |
| C02 | /api/v1/device/register | challenge_id、boot_id、hardware_model、hardware_revision、firmware_version、sdk_version | 认证已有生产身份;返回当前状态和契约版本 |
| C03 | /api/v1/device/setup-sessions | boot_id、local_setup_id、transport_profile=LOCAL_WEB | setup_id、期限、轮询间隔、状态 |
| C04 | /api/v1/device/handoffs | setup_id、local_setup_id、code_challenge | 一次性 token、期限、固定 HTTPS 入口 |
| C05 | /api/v1/device/poll | setup_id、boot_id、known_revision | 当前状态、有效流程命令、轮询间隔 |
| C06 | /api/v1/device/claim-confirmations | claim_id、challenge、binding_digest、expected_binding_generation、press_event_id | 提交绑定或返回同一已提交结果 |
| C07 | /api/v1/device/state-acks | device_revision、binding_generation、local_generation、state_hash | 保存设备落盘证据及可否收尾 |
| C08 | /api/v1/device/activation-attempts | activation_id、config_version、attempt_id | 持久化可能调用状态,返回 30 秒执行许可 |
| C09 | /api/v1/device/activation-results | activation_id、attempt_id、stage、sdk_result、outcome | 更新注册/通话验收状态或转入对账 |
| C10 | /api/v1/device/activation-acks | activation_id、local_generation、config_version | 保存激活配置落盘回执 |
| C11 | /api/v1/device/setup-close | setup_id、reason、final_revision | 关闭临时流程,保留绑定和激活任务 |
公共状态包含 device_id、device_revision、binding_generation、binding_state、activation_state、next_action、state_hash。C05 的状态恢复权限不能随手机 token 或原 setup 过期而消失;需要时先创建仅用于恢复的新 setup。
### 7.2 手机调用 SaaS
| POST 路径 | 核心输入或上下文 | 用途 |
|---|---|---|
| /api/v1/web/handoffs/exchange | token、verifier | 登录前兑换一次性入口,创建当前 setup 的浏览器会话 |
| /api/v1/web/auth/sms/send | 手机号和当前流程;附件未完整定义字段 schema | 发送登录验证码并限流 |
| /api/v1/web/auth/sms/verify | 手机号、验证码和当前流程;附件未完整定义字段 schema | 验证、登录或注册,更新会话并保留 setup |
| /api/v1/web/claims | elder_id、expected_binding_generation、登录会话 | 创建待设备按键确认的绑定申请 |
| /api/v1/web/setup/status | 浏览器当前 setup | 查询自己的绑定和激活进度 |
| /api/v1/web/setup/ack | 浏览器当前 setup 及收到结果的上下文 | 确认页面已收到结果 |
| /api/v1/web/claims/cancel | 当前 setup 的待确认申请 | 取消待确认申请;不解绑已经归属的设备 |
设备收到的 masked_account 只用于提示核对,不接受它作为认证依据。用户身份从登录会话取得;不能信任浏览器提交的 owner_user_id,也不能允许浏览器仅凭任意 device_id 查询其他家庭的状态。
### 7.3 可复用的当前入口
| 当前接口或模块 | 可复用范围 | 无法直接替代的部分 |
|---|---|---|
| POST /api/v1/ops/devices | 运维建档、初始平台凭据 | 生产 MAC 幂等登记和 C02 设备认证注册 |
| POST /api/v1/ops/devices/{id}/credentials/rotate | 受控凭据更新 | ONBOARDING-1.1 按版本认证和产线恢复协议 |
| GET /device/v2/bootstrap | 在线报告、当前绑定和固件兼容检查 | 首次时间挑战、setup、绑定代数与完整恢复状态 |
| GET /device/v2/config | 配置查询与版本基础 | CFG1 配置、绑定代数和明确的本地保存 ACK |
| POST /api/v1/device-claims | 家庭权限、绑定码、绑定写入与唯一约束 | 登录后的待确认申请及实体确认挑战 |
| GET /device/v2/commands 及命令 ACK | 命令投递和回执基础 | 四种首次接入命令与具体绑定确认语义 |
| POST /device/v3/hardware-agent-credentials:provision | 平台代注册、固定设备名、加密凭据及复用 | 独立激活任务、执行许可、未知结果对账和 READY 验收 |
| binding-impact / binding-change | 当前家庭权限、解绑与变更前检查 | 新确认流程、绑定代数与所有旧入口失效策略 |
| OIDC、家庭和使用者服务 | 账号认证、成员权限、档案管理 | 手机 handoff、setup Cookie、附件指定短信 schema |
## 8 数据模型和后台页面
以下是逻辑记录划分,可按现有结构扩展,不要求每一项都另建一张表。
| 逻辑记录 | 关键数据和约束 |
|---|---|
| 生产库存和凭据 | 唯一工厂 ID/MAC、内部设备 UUID、租户归属、型号、密钥引用及版本、生产/返修状态 |
| 时间挑战 | 设备、版本、boot_id、client_nonce、签发/失效/消费时间及注册结果关联 |
| 设备当前状态 | 独立绑定和激活状态、device_revision、binding_generation、当前任务、最后在线时间 |
| 设置会话和交接入口 | setup/local_setup/boot 关联、模式、权限、绝对期限、token 哈希、verifier 摘要、消费状态 |
| 绑定申请和绑定历史 | 目标账号/家庭/使用者、挑战、digest、按键事件、期限、代数;唯一有效申请和归属 |
| 状态快照和回执 | 状态 hash、设备保存版本、页面看到的结果、ACK 时间;旧版本不能覆盖新版本 |
| 激活任务和尝试 | 固定供应商身份、权益预留、attempt、许可期限、调用阶段、结果证据、待对账原因 |
| 幂等结果和 outbox | 设备/接口/请求号作用域、原始 body 摘要、提交结果;长期业务唯一键;待办重试和消费去重 |
| 审计记录 | 操作者、设备、setup、claim、activation/attempt、旧/新状态及错误码;不记录密钥和验证码 |
用户侧至少需要:入口恢复页、登录页、家庭/使用者选择页、等待按键确认页、绑定完成及设备保存状态、AI 服务进度、正常账号设备列表。
运维侧至少需要:按工厂编号/MAC 查库存与设备、出厂放行与凭据轮换、首次接入阶段诊断、当前绑定及历史代数、设备/页面 ACK 状态、激活异常与人工对账记录、停用和报失处置。
## 9 附件在当前 SaaS 中还需明确的设计点
1. **租户和 owner 映射。** 当前设备、家庭和档案受租户约束,归属主要通过 household/subject 表达。附件没有规定新手机号注册到哪个租户、能否认领其他租户库存,以及 owner 是家庭还是单一账号。建议按生产归属解析租户,并继续验证家庭管理权限;不要直接让手机号或 elder_id 决定租户。
2. **身份编码迁移。** 外部 KP01 编号与现有 UUID 保持稳定映射;产线、认证、售后和旧客户端不得各自生成新身份。
3. **绑定代数的变更规则。** 附件规定归属变化递增,但需要进一步固定“解绑后再绑定”和“一步转移”的递增及历史记录规则。所有写入口必须统一。
4. **普通重新配网与恢复出厂。** 当前 /device/v3/factory-reset 会解除绑定并生成新绑定码。附件规定普通重配网保留 owner,转让走原账号授权。因此普通重配网不能调用此接口,完整恢复出厂的归属权限也需要单独定规则。
5. **激活执行位置与授权模型。** 统一平台代注册还是设备 SDK 注册、ProductSecret 的位置、名额与供应商权益的对应关系,以及什么证据足以解除未知结果状态。
6. **接口详细 schema。** 短信输入、取消目标、浏览器 ACK 的版本匹配、部分恢复错误及晚到结果规则仍需写入 OpenAPI。公共约定要求变更请求带 X-Request-ID,浏览器这些写操作也应明确其用法。
7. **状态快照和 ACK。** 明确 device_revision 对应的历史状态保留策略;当前状态继续推进时,设备应同步最新版本,不能把旧 ACK 写成新版本已保存。
8. **存量入口。** 现有绑定码、运维分配、解绑转移和恢复出厂均会影响归属。新协议不能只覆盖新页面而遗漏其他写入口。
## 10 建议开发顺序与验收
第一阶段先完成认证与绑定:生产身份映射 → 新鉴权与时间挑战 → 注册和公共状态 → setup/handoff → 登录与使用者选择 → 待确认申请 → 设备确认事务 → 两侧回执与收尾。此阶段可返回“已绑定,AI 服务待开通”,不阻塞于真实供应商联调。
第二阶段补齐激活:绑定 outbox → 固定激活任务与身份 → 调用尝试和许可 → 结果上报与对账 → 激活配置 ACK → 首音频验收后的 READY。
第三阶段完成运营闭环:停用和密钥轮换、返修、原账号解绑与新账号确认、旧代数隔离、失败恢复入口、诊断视图及生产环境限流监控。
必须覆盖的验收结果:
- 同 MAC/工厂 ID 重复生产登记和重复上线,不增加设备副本,也不清空已有绑定。
- 错密钥、错版本、过期时间、重放 nonce 被拒绝;同请求号不同 body 返回冲突。
- handoff 仅能兑换一次,过期不能复活;登录和浏览器切换后可恢复正确设备上下文。
- 无设备确认不能绑定;双账号并发仅一条有效申请和一个有效归属。
- 旧 challenge、旧按键、被取消 claim、旧绑定代数不能建立新归属。
- 绑定提交后回包丢失,返回同一有效结果;设备保存失败只补写和 ACK,不撤销绑定。
- 页面关闭、setup 到期或热点关闭后,设备仍能恢复原激活任务。
- 供应商结果未知保留原任务、设备名和可能占用的权益;不自动释放后再次分配。
- SDK 注册成功与真实通话 READY 分开;授权不足保留账号绑定。
- 普通重配网保留归属;正式解绑或转移使旧会话、旧申请和旧用户配置失效。
## 11 当前代码依据
- [实际路由与设备鉴权分组](E:/Codex/story/platform/internal/httpapi/router.go:297)
- [运维登记设备和生成平台凭据](E:/Codex/story/platform/internal/opsdevice/service.go:244)
- [当前设备 HMAC 验证和签名正文](E:/Codex/story/platform/internal/deviceauth/verifier.go:69)
- [设备凭据加密存储与轮换](E:/Codex/story/platform/internal/deviceauth/database_secret.go:115)
- [bootstrap 的现有状态字段和报告](E:/Codex/story/platform/internal/device/repository.go:32)
- [当前提交绑定码后直接绑定的逻辑](E:/Codex/story/platform/internal/saas/device_claim.go:27)
- [有效设备绑定唯一约束](E:/Codex/story/platform/migrations/000005_device_conversation.up.sql:65)
- [当前 claim 数据结构](E:/Codex/story/platform/migrations/000007_device_claim.up.sql:3)
- [当前设备生命周期状态](E:/Codex/story/platform/internal/deviceactivation/state.go:15)
- [当前解绑和转移事务](E:/Codex/story/platform/internal/saas/device_binding_service.go:67)
- [当前命令类型约束](E:/Codex/story/platform/migrations/000011_device_command.up.sql:8)
- [平台代注册与供应商凭据保存](E:/Codex/story/platform/internal/hardwareprovision/service.go:127)
- [License 模块](E:/Codex/story/platform/internal/hardwarelicense/service.go:250)
- [恢复出厂解除绑定](E:/Codex/story/platform/internal/devicereset/service.go:168)
- [现有前端 OIDC 登录](E:/Codex/story/platform/web/src/auth/oidc.ts:6)
- [通用 outbox 执行基础](E:/Codex/story/platform/internal/outbox/worker.go:1)
@@ -0,0 +1,13 @@
# For more information about build system see
# https://docs.espressif.com/projects/esp-idf/en/latest/api-guides/build-system.html
# The following five lines of boilerplate have to be in your project's
# CMakeLists in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.5)
# include($ENV{IDF_PATH}/tools/cmake/project.cmake)
#include($ENV{ADF_PATH}/CMakeLists.txt)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(VolcRTCDemo)
# add_compile_definitions(LWIP_DEBUG=1)
+211
View File
@@ -0,0 +1,211 @@
<h1 align="center"><img src="https://iam.volccdn.com/obj/volcengine-public/pic/volcengine-icon.png"></h1>
<h1 align="center">ConversationalAI Embedded Kit 2.0 </h1>
## EchoEar 平台版固件
本目录是在火山官方 Embedded Kit 2.0 基础上适配的 ESP32-S3 EchoEar 固件。它使用低负载 WebSocket 方案,并将设备身份、动态注册和会话配置统一接入自有 SaaS。
安全边界:
- 固件和设备端都不包含火山 `Product Secret`;
- SaaS 使用平台设备身份为 EchoEar 动态申请火山 `Device Secret`;
- EchoEar 只保存自己的平台设备凭据和火山单设备凭据;
- 每次对话前由 SaaS 下发 Bot、人设、绑定用户的非密钥 Viking 查询范围和会话配置;
- 字幕和最终轮次回传 SaaS,供会话记录与长期记忆处理。
当前测试硬件:ESP32-S3 EchoEar,16 MB Flash、16 MB PSRAM,硬件标识由 STA MAC 生成(当前板为 `echoear-b43a451634a8`)。
每次申请对话前,先通过 `/device/v2/bootstrap` 上报实际固件版本并检查 `firmware_compatible`,再获取配置和创建会话。固件版本为 `1.2.2-echoear-companion.3`;本工程基于 Embedded Kit 2.0,硬件智能体 SDK 版本独立上报为 `2.0.0`。会话能力中的 `sdk_version` 不能用固件版本代替。动态 `session.update` 同时配置端到端语音、混合编排 LLM、长者自然陪聊约束,以及按当前绑定用户隔离的 Viking `MemoryConfig`;端到端语音凭据和 Viking API Key 均不进入固件。会话尚未创建时保持唤醒采集,不重复拆建音频管线。SPI 屏幕仅在 `on_color_trans_done` 后释放 GFX 帧缓冲,避免异步传输期间提前复用缓冲区。运行状态用眼睛动画表示,避免 80 MHz 下中文逐像素渲染长期占用 CPU:快速眨眼表示聆听,转圈表示思考,正常眼睛表示待机或回答。
本板构建使用 16 MB Flash,Flash 与八线 PSRAM 均为 80 MHz。通用默认配置、ESP32-S3 默认配置和当前 `sdkconfig` 必须一致;不要恢复上游的 32 MB Flash 或实验性 120 MHz PSRAM 参数。
唤醒词为“嗨,乐鑫”,启用 `CONFIG_SR_WN_WN9_HILEXIN`,使用 `DET_MODE_95` 高灵敏度检测,并将 AFE 线性增益设为 2.0。生成的 `build/srmodels/srmodels.bin` 必须包含 `wn9_hilexin` 模型(约291 KB),并烧录到 `model` 分区 `0x510000`;不能使用未选择任何模型时生成的4字节空文件。
EchoEar V1.2 的 ES7210 使用 TDM:I2S 按两路32位接收,AFE 按四路16位解释,布局为 `RMNM`(ch1/ch3 麦克风、ch0 回声参考、ch2 忽略)。不可改成两路16位 `MR`。诊断日志 `MIC_LEVEL` 仅记录各路幅度统计,不保存录音。PCM输出每帧60 ms、16 kHz、单声道16位,读取缓冲区为1920字节,避免截断半帧。唤醒事件在会话服务就绪后直接发布,不依赖已移除的 `CONFIG_LANGUAGE_WAKEUP_MODE`。
低负载 WebSocket 任务使用 12 KiB 栈;4 KiB 会在收到 `session.created` 后发生栈溢出并重启。握手期间暂停上传麦克风帧,待引擎进入 STARTED 后再发送。低负载接口返回的 PCM 按 24 kHz 单声道16位解码,再由播放管线转换到板载 16 kHz I2S。`session.created` 的 `job_id` 用作平台启动确认,确保后续字幕和结束事件在 ACTIVE 会话上提交。
联网、平台 HTTPS 激活和音频初始化在独立的 24 KiB 启动任务中执行,避免默认 4 KiB `main` 栈溢出导致循环重启;任务退出时释放栈,并记录启动阶段最小栈余量。平台激活成功后才启动音频采集。激活失败时保留设备配置、显示绑定或配置提示,并仅记录平台错误码,不输出响应正文或凭据。
STA 连接使用全信道扫描并按信号强度选择同名 AP,实时语音期间关闭 Wi-Fi 省电,避免多 AP 环境中误连弱信号节点造成 WebSocket 写入失败。
### Windows 编译与烧录
使用 ESP-IDF v5.5.1:
```powershell
. 'E:\Codex\toolchains\esp-idf-v5.5.1\export.ps1'
idf.py build
idf.py -p COM6 flash
idf.py -p COM6 monitor
```
首次烧录前建议完整备份 Flash。当前板原厂备份位于 `backups/b43a451634a8/factory-2026-09-03-16mb.bin`,恢复命令:
```powershell
esptool.py --chip esp32s3 --port COM6 write_flash 0x0 backups/b43a451634a8/factory-2026-09-03-16mb.bin
```
### 首次激活
首次启动、缺少平台设备凭据、保存的 Wi-Fi 连接失败或超时,以及手动请求重新配网时,都会进入配置热点,无需清空 NVS:
1. 连接 `VolcConvAI-XXXXXX`,热点密码与热点名相同;
2. 打开 `http://192.168.4.1`;
3. 填写 2.4 GHz Wi-Fi、平台 Device ID 和平台 Device Secret;
4. 重启后,设备通过 SaaS 完成火山动态注册并获取单设备运行凭据。
正常运行时短触圆屏任意位置可直接开始对话,作为唤醒词的可靠备用入口。屏幕底部区域持续按住3秒后设备重启并开启配置热点。该操作保留原来的 Wi-Fi 和设备身份,只记录重新配网请求;成功提交配网页后退出配网模式。触摸任务在联网和平台激活之前创建,因此激活失败时也可以请求重新配网。
如果尚未配置平台设备身份,固件不会尝试用 Product Secret 直接访问火山。
重新配网回归检查(需要烧录后实机验证):
- 已保存可用 Wi-Fi、但缺少平台凭据:启动后直接出现热点和配网地址。
- Wi-Fi 密码错误或 DHCP 超时:连接等待结束后进入热点,不能重启循环。
- 正常运行时:短触屏幕启动对话;按住屏幕底部区域3秒进入配置热点。
- 配网页提交错误 Wi-Fi 后可重试;只有取得 IP 地址后才保存配置并重启。
- 配网页成功提交后正常启动,手动配网请求清除,原平台身份不会因进入配网而被删除。
本地 `idf.py build` 仅验证编译、链接和分区大小,不代替热点可见性、触摸坐标及长按时长的实机验证。
配网页提交使用 JSON 与后台连接任务,`POST /submit` 受理后立即返回 202,页面通过 `GET /setup-status` 查询连接、保存成功或失败状态。连接失败可以重试;保存成功后等待15秒重启,再由启动流程执行平台激活。Wi-Fi 扫描改为手动触发,连接期间不扫描。更新固件后请刷新配网页,避免继续使用已经打开的旧表单。
配网页交互回归:`node --test tests/setup-portal.test.cjs`(模拟设备响应,覆盖失败重试、响应丢失、断联、成功提示和输入校验)。
## 快速开始
具体操作,请参考 [官网文档](https://www.volcengine.com/docs/6348/1806625)。
## 运行设备端(乐鑫)
以下操作以 macOS 操作系统为例。
### 环境与硬件要求
- 乐鑫 ESP32-S3-Korvo-2
- USB 数据线:两条 A 转 Micro-B 数据线,一条作为电源线,一条作为串口线。
- PC 设备:编译和烧录。支持 Windows、Linux 或者 macOS 操作系统。(本文操作以 macOS 为例)
### 配置乐鑫环境
详见[开发环境配置文档](https://docs.espressif.com/projects/esp-idf/zh_CN/stable/esp32s3/get-started/index.html)。
1. 安装 CMake 和 Ninja 编译工具。
```bash
brew install cmake ninja dfu-util
```
2. 将乐鑫 ADF 框架克隆到本地,并同步各子仓(submodule)代码。
> **注意**:Demo 中使用的 ADF 版本为 `eca11f20e56f9b5321b714da4305e123672d92a9`,对应 IDF 版本为 `v5.4`,请确保 ADF 版本与 IDF 版本匹配。
>
```bash
# 1. clone 乐鑫 ADF 框架
git clone https://github.com/espressif/esp-adf.git
# 2. 进入esp-adf目录
cd esp-adf
# 3. 切换到乐鑫 ADF 指定版本
git reset --hard eca11f20e56f9b5321b714da4305e123672d92a9
# 4. 同步各子仓代码
git submodule update --init --recursive
```
3. 安装乐鑫 esp32s3 开发环境相关依赖。
```bash
./install.sh esp32s3
```
成功安装所有依赖后,命令行会出现如下提示:
```bash
All done! You can now run:
. ./export.sh
```
> 如在上述任何步骤中遇到以下错误:
> `<urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:xxx)`
> 可前往**访达->应用程序->Python** 文件夹,点击 `Install Certificates.command` 安装证书。更多信息,请参考 [安装 ESP-IDF 工具时出现的下载错误](https://github.com/espressif/esp-idf/issues/4775)。
>
4. 设置环境变量。
> **每次打开命令行窗口均需要运行该命令进行设置。**
>
```bash
. ./export.sh
```
### 下载并配置工程
1. 将实时对话式 AI 硬件示例工程克隆到 乐鑫 ADF examples 目录下。
1. 进入 esp-adf/examples 目录。
```bash
cd $ADF_PATH/examples
```
2. 克隆实时对话式 AI 硬件示例工程。
```bash
git clone https://github.com/volcengine/ConversationalAI-Embedded-Kit-2.0.git
```
2. 修复乐鑫按键问题
1. 进入 esp-adf 目录。
```bash
cd $ADF_PATH
```
2. 修复乐鑫按键问题。
```bash
git apply $ADF_PATH/examples/ConversationalAI-Embedded-Kit-2.0/low_load_first/espressif/0002-fix-esp-button.patch
```
### 编译固件
进入 `esp-adf/examples/ConversationalAI-Embedded-Kit-2.0/low_load_first/espressif` 目录下编译固件。
1. 进入 espressif 目录。
```bash
cd $ADF_PATH/examples/ConversationalAI-Embedded-Kit-2.0/low_load_first/espressif
```
2. 设置编译目标平台。
```bash
idf.py set-target esp32s3
```
3. 设置 实例ID、产品ID、产品秘钥、设备ID等参数。
```bash
idf.py menuconfig
```
进入 `Example Configuration` 菜单,在 `volcano instance id` 中填入你的实例ID,在 `volcano product key` 中填入你的产品Key,在 `volcano product secret` 中填入你的产品秘钥,在 `device name` 中填入你的设备ID, 在 `bot id` 中填入你的智能体ID,并保存。
4. 编译固件。
```bash
idf.py build
```
### 烧录并运行示例 Demo
1. 打开乐鑫开发板电源开关。
2. 烧录固件。
```bash
idf.py flash
```
3. 运行示例 Demo 并查看串口日志输出。
```bash
idf.py monitor
```
4. Wi-Fi 配网。
1. 手机找到名如 VolcConvAI-XXXXXX” 的 Wi-Fi 热点,密码同Wi-Fi名,连接上 Wi-Fi。
2. 打开浏览器,在地址栏输入 `http://192.168.4.1`,进入 Wi-Fi 配网页面。
3. 输入 Wi-Fi 名称和密码,点击提交。
> **注意**:如果需更换 Wi-Fi,请重启设备。如果设备重启后无法连接到之前保存的 Wi-Fi(例如超出了范围或旧网络已关闭),请等待 30s 进入配网模式,再重新执行上面 Wi-Fi 配网的 3 个步骤。
>
Binary file not shown.
@@ -0,0 +1,27 @@
cmake_minimum_required(VERSION 3.16)
message(STATUS "................. ${CMAKE_SYSTEM_NAME}")
if("${CMAKE_SYSTEM_NAME}" MATCHES "(Rtos|rtos|RTOS)+")
set(PLATFORM_RTOS TRUE)
add_compile_definitions(PLATFORM_RTOS)
elseif("${CMAKE_SYSTEM_NAME}" MATCHES "(Linux|linux|LINUX)+")
set(PLATFORM_LINUX TRUE)
add_compile_definitions(PLATFORM_LINUX)
elseif("${CMAKE_SYSTEM_NAME}" MATCHES "(android|Android|ANDROID)+")
set(PLATFORM_ANDROID TRUE)
add_compile_definitions(PLATFORM_ANDROID)
elseif("${CMAKE_SYSTEM_NAME}" MATCHES "(Darwin|darwin|MACOSX|macosx|MACOS|macos)+")
set(PLATFORM_MACOS TRUE)
add_compile_definitions(PLATFORM_MACOS)
endif()
include(${CMAKE_CURRENT_LIST_DIR}/cmake/common.cmake)
if(ESP_PLATFORM)
message(STATUS "ESP_PLATFORM is defined, including esp.cmake")
include(${CMAKE_CURRENT_LIST_DIR}/cmake/espressif.cmake)
return()
elseif(PLATFORM_MACOS)
message(STATUS "PLATFORM_MACOS is defined, including macos.cmake")
include(${CMAKE_CURRENT_LIST_DIR}/cmake/macos.cmake)
return()
endif()

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