Files
story/docs/embedded-device-handoff-20260914.md
T

738 lines
54 KiB
Markdown
Raw Normal View History

# VoiceLink 设备接入交接文档
> 历史技术明细:本文描述旧的“设备自行注册火山”流程。当前代码已改为平台代注册,新的接口与迁移说明见同目录《平台注册火山与设备取参.md》和《设备接口文档-MQTT与OTA版.md》。本文件保留作旧协议参考,不用于指导新固件注册。
版本:1.1 / 2026-09-14。交付对象:嵌入式开发人员。本版更新蓝牙名称及小程序筛选规则,未改设备身份或密钥。
依据:线上后端 `0.90.0-rc.wechat1`、EchoEar 固件 `1.5.0-elder-agent.2`、VoiceLink 小程序 `wx4553119e05f11660` 的实际实现。
本文是当前方案:**明文 BLE 配网绑定 + 设备向平台申请开聊参数 + 设备直连火山 RTC + 火山回调文字 + 设备上报结束**。每位老人一个智能体,平台负责创建、保存、配置;设备不创建老人或智能体。
不要混用旧文档中的 `/device/v2/*`、`/device/v3/agent-sessions`、`/api/v1/device/calls/*`、旧网页 setup/handoff 接口。当前蓝牙无 `salt/verifier/session_password/client_nonce/server_nonce/proof` 握手。平台 HTTPS 的工厂密钥签名仍在实际代码中,本文如实列出,不是新增功能。
## 1. 已创建的交接设备与初始化
| 项目 | 本次设备值 | 中文解释与用途 |
|---|---|---|
| 工厂设备编号 `factory_id` / 接口 `device_id` | `KP01-020000260914` | BLE 自报和所有设备请求中的身份;不要使用平台 UUID 替代 |
| 生产编号后缀 `factory_mac` | `020000260914` | 本次人为保留的联调编号,不是读取到的实物 MAC;不要修改网卡真实 MAC 来迁就它 |
| 平台设备 UUID | `f9839cde-3078-4edc-a40a-c2dbe284fad6` | 平台列表、详情、解绑使用;设备协议不使用它作为 X-Device-ID |
| 型号 `model` | `ECHOEAR-S3` | BLE get_info 输出;注册 hardware_model 必须一致 |
| 硬件版本 | `V1.2` | register 的 hardware_revision 必须与此一致 |
| 后端地址 `origin` | `https://121.41.46.238` | 无末尾斜线;设备 HTTP 请求拼接下表路径 |
| 蓝牙广播名 | `voicelink-ECHOEAR-S3-00260914` | voicelink- + 型号 + - + 工厂编号末 8 位;小程序仅按服务 UUID 搜索,不按名称拒绝设备 |
| 平台密钥版本 `key_version` | `1` | 请求头、校时请求携带 |
| 平台设备密钥 | 同目录 `device-init.private.json` | 本设备专属随机 32 字节;不在通用文档明文展示 |
| BLE 协议版本 | `2` | get_info 的 protocol_version,必须是数字 2 |
| 平台接入契约标识 | `ONBOARDING-1.1` | 当前响应中的实际值;不是 BLE 版本,也不是聊天接口路径 |
| 当前状态 | `CLAIMABLE`、`released=true`、未绑定 | 平台已登记放行,尚未绑定任何老人、未做火山动态注册 |
| 登记批次 | `EMBEDDED-HANDOFF-20260914-TEST` | 后台查找这台联调设备 |
本次只给一台联调板使用这份身份,**不能把同一份密钥和编号烧进多台设备**。正式生产应读取各板真实 MAC,按对应编号逐台登记并分配独立密钥;本次测试身份可直接写入应用身份区联调,固件并不要求它等于网卡当前地址。若硬件并非 EchoEar,需要移植屏幕确认、音频驱动、存储、RTC SDK,不应照抄 EchoEar 分区偏移去烧录。
### 1.1 烧录/配置字段表
| 字段 | 类型/存储 | 中文解释与用途 | 如何取得 |
|---|---|---|---|
| `factory_id` | UTF-8 字符串 | 平台设备身份,17 字符,不含 NUL 终止符长度 | 上表及私有 JSON |
| `model` | 字符串,1–32 字节 | 型号 | `ECHOEAR-S3` |
| `origin` | 字符串 | 平台 HTTPS 根地址 | 上表 |
| `device_key` | **32 字节二进制** | 平台 HMAC 密钥 | 将私有 JSON 的 `device_key_hex` 每两位解码为 1 字节;或解码 `device_key_b64`,二者是同一密钥,不是两把 |
| `key_version` | uint32 | 平台密钥版本 | `1` |
| `hardware_revision` | 编译常量或持久化字符串 | 每次注册上报实际登记版本 | `V1.2` |
| TLS 信任根 | 系统/SDK CA 证书集合 | 校验平台及火山 HTTPS 证书;设备先校时再 TLS | 支持根证书更新,勿关闭证书验证;可使用 ESP-IDF crt bundle |
现有 ESP-IDF 代码从 `ble_factory` NVS 分区、`identity` 命名空间读取前 5 项:`factory_id/model/origin` 是 NVS string,`device_key` 是 blob,`key_version` 是 u32。其它 MCU 使用等价持久化区即可。BLE 服务 UUID 是编译常量,无需逐台烧录。旧 salt/verifier 不需要生成、读取或发送。
**初始化时不要烧录** Wi-Fi、手机号、账号密码、老人 ID、bind_token、bot_id、S2SConfig、MemoryConfig、微信 AppSecret、火山管理 AK/SK。Wi-Fi 和临时绑定令牌来自小程序;火山产品配置来自平台;注册得到的火山设备密钥和 RTCAppID 运行时保存。平台注册密钥与火山设备密钥不是同一个东西。
新板用户配置区初始为空:没有 Wi-Fi、没有待绑定任务、没有火山 device_secret/RTCAppID、没有未结束会话;首次启动生成新的 boot_id。不需要预生成 session_id、attempt_id、client_nonce;它们按流程产生。不要把参考板的运行期 NVS 整块克隆过来。
## 2. 完整实现顺序
| 顺序 | 谁做 | 动作/结果 |
|---|---|---|
| 1 | 设备 | 读取初始化身份,进入可连接 BLE 配网广播 |
| 2 | 小程序 | 用户登录,搜索、连接、订阅 TX,get_info 识别工厂编号和型号 |
| 3 | 小程序→平台 | 查是否已绑定;未绑定选择老人并拿 bind_token,已绑定使用空字符串 |
| 4 | 小程序→设备 | configure 发送 Wi-Fi 和 bind_token;设备先回复 received |
| 5 | 设备 | 等待 60 秒内轻点屏幕确认;确认后持久化任务,连接 Wi-Fi |
| 6 | 设备→平台 | 校时;有令牌则 ble-bind,空令牌则 ble-state 检查仍已绑定 |
| 7 | 设备 | 云端成功才保存 Wi-Fi,BLE get_status 返回 success;可约 15 秒后重启 |
| 8 | 设备→平台 | 每次新启动:新 boot_id → time-challenges → register → voice-runtime |
| 9 | 设备→平台/火山 | 首次没有火山设备凭据:activation-attempts → SDK 动态注册 → 保存凭据 → activation-results |
| 10 | 设备→平台 | 初始化 RTC SDK,保存配置后 activation-acks;已有凭据时跳过重复注册,但每次启动仍获取配置并确认 |
| 11 | 设备→平台 | 每次聊天新 session_id,chat/start 检查绑定并获得老人的 bot_id、params |
| 12 | 设备↔火山 | 用 SDK 获取房间、加入 RTC、采集和播放音频;聊天中每 10 秒重复 chat/start 续期 |
| 13 | 火山→平台 | 字幕回调自动保存聊天文字;设备不用上传聊天文字 |
| 14 | 设备→平台 | 先停音频和 SDK,再 chat/end;失败保留结束任务补报 |
平台创建老人时创建专属智能体、配置豆包 S2S 并关联火山硬件产品。嵌入式只选择平台本次返回的 bot_id,不能把某一老人 bot_id 固化成所有设备共用值。
## 3. 接口地址总表
以下 H = `https://121.41.46.238`。设备接口均由设备主动发起,平台通过响应返回数据,不需要在设备上开 HTTP 服务。
| 编号 | 调用方向 | 方法和完整地址/通道 | 接口描述 |
|---|---|---|---|
| B1 | 小程序→设备 | BLE RX / `get_info` | 获取设备身份、型号、协议版本 |
| B2 | 小程序→设备 | BLE RX / `configure` | 发送 Wi-Fi 和绑定令牌 |
| B3 | 小程序→设备 | BLE RX / `get_status` | 查询配网绑定结果 |
| M1 | 小程序→平台 | POST `https://121.41.46.238/api/v1/mini/ble-device-state` | 权限校验、查询绑定状态 |
| M2 | 小程序→平台 | POST `https://121.41.46.238/api/v1/mini/ble-bind-tokens` | 给未绑定设备申请短期绑定令牌 |
| M3 | 小程序→平台 | POST `https://121.41.46.238/api/v1/mini/ble-bind-status` | 查询令牌对应的绑定结果 |
| M4 | 小程序→平台 | GET `https://121.41.46.238/api/v1/devices` | 当前账号设备列表 |
| M5 | 小程序→平台 | GET `https://121.41.46.238/api/v1/devices/{id}/binding-impact?action=unbind` | 查询解绑影响,id 为平台 UUID |
| M6 | 小程序→平台 | POST `https://121.41.46.238/api/v1/devices/{id}/binding-change` | 用户确认解绑 |
| D1 | 设备→平台 | POST `http://121.41.46.238/api/v1/device/time-challenges` | 启动校时,唯一 HTTP 特例 |
| D2 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/ble-bind` | 兑现绑定令牌 |
| D3 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/ble-state` | 只读查询是否绑定 |
| D4 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/register` | 当前启动注册,不是创建库存设备 |
| D5 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/voice-runtime` | 获取当前绑定的语音注册配置 |
| D6 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/activation-attempts` | 获取一次 SDK 注册执行许可 |
| D7 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/activation-results` | 上报 SDK 注册真实结果 |
| D8 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/activation-acks` | 确认配置已保存、SDK 已初始化 |
| D9 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/chat/start` | 开聊检查/聊天续期 |
| D10 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/chat/end` | 上报一次聊天结束 |
| V1 | 设备 SDK→火山 | POST `https://iot-cn-shanghai.iot.volces.com/2021-12-14/DynamicRegister?Action=DynamicRegister&Version=2021-12-14` | 获取火山设备密钥和 RTCAppID |
| V2 | 设备 SDK→火山 | POST `https://iot-cn-shanghai.iot.volces.com/2021-12-14/GetRTCConfig?Action=GetRTCConfig&Version=2021-12-14` | 获取本次 RTC 房间参数 |
| C1 | 火山→平台 | POST `https://121.41.46.238/callbacks/v1/volcengine/chat?session_id={session_id}` | 聊天文字回调;设备不调用 |
V1/V2 为当前工程内火山 SDK 的调用地址,优先复用 SDK,不把 RTC 当成普通 WebSocket。平台当前 IP 地址可用于设备 HTTPS;小程序正式发布的 request 合法域名配置是另一项工作。
## 4. 小程序与设备:BLE GATT
### 4.1 发现、连接和传输
| 项目 | 值 | 用途 |
|---|---|---|
| 服务 UUID | `8e7a2000-6f4b-4a2d-9c31-7d824f5e6100` | 放入广播/扫描响应的 128-bit Service UUID,小程序按它搜索 |
| RX UUID | `8e7a2001-6f4b-4a2d-9c31-7d824f5e6100` | 属性 Write,手机→设备,带应答写入 |
| TX UUID | `8e7a2002-6f4b-4a2d-9c31-7d824f5e6100` | 属性 Indicate,设备→手机,提供 CCCD 0x2902,等待 indication 确认后发下一片 |
| 广播名称 | `voicelink-ECHOEAR-S3-00260914` | 格式 voicelink-型号-xxxxxxxx,末尾为工厂编号后 8 位大写十六进制 |
| 协议 | 明文 UTF-8 JSON | 不要求 BLE 配对,不做业务加密握手 |
| 消息边界 | 2 字节小端长度 + JSON 字节 | 长度不含自身 2 字节,也不含 C 字符串结尾 NUL |
| 分片 | 每片最多 20 字节 | 同一逻辑消息仅头部出现一次长度,后续直接续传 |
| 消息长度 | 1–2048 字节 JSON | 帧最多 2050 字节;不支持同时并发多条命令 |
| 超时 | 整条消息 45 秒,无分片进展 10 秒 | 不完整帧超时断开并清缓冲,不能把部分 JSON 当作完整请求 |
小程序顺序:openBluetoothAdapter → startBluetoothDevicesDiscovery(services=[服务 UUID])→ createBLEConnection → getBLEDeviceServices → getBLEDeviceCharacteristics → notifyBLECharacteristicValueChange(type=indication)→ 写 RX。订阅后约等 150 ms 再写入。必须在 TX 已订阅后发送应答。扫描约 20 秒停止,可重搜。
服务 UUID 是发现条件,名称仅用于显示,不是身份认证;旧 EchoEar 名称、自定义名称如 abcd-232223 也不会因名称被过滤,无名称时显示“未命名设备”。若扫描回调明确给出服务 UUID 列表,则也检查其中包含配网 UUID;未提供列表时依赖系统扫描过滤,连接后再次发现服务/特征并执行 B1。手机蓝牙 API 的 `deviceId` 是操作系统临时标识,只用于连接;业务 `device_id` 从 B1 读取。UUID 字符串比较可忽略大小写。广播 UUID 的字节序以蓝牙栈 API 为准,不能把文本 UUID 直接当原始 16 字节发送。
当前使用传统 31 字节扫描响应,名称 AD 的长度和类型占 2 字节,可用名称最多 29 字节。命名分配:`voicelink-` 10 字节 + 型号最多 10 个 UTF-8 字节 + `-` 1 字节 + 后缀 8 字节。超长型号只在广播名中按完整 UTF-8 字符边界缩短,B1 的 model 不缩短。GAP Device Name 和扫描响应使用同一个名称。原 EchoEar 实物编号 KP01-B43A451634A8 对应 `voicelink-ECHOEAR-S3-451634A8`;本次联调编号对应表中 `00260914`,这不改变编号本身。
帧示例(可直接比对十六进制):
```text
JSON: {"cmd":"get_info"}
UTF-8 长度: 18 = 0x0012
完整帧 hex: 12007b22636d64223a226765745f696e666f227d
```
不使用换行分帧,不对每个 BLE 分片单独加头,不在 JSON 中加入包序号。按长度累加,完整后只处理一个对象。设备仅响应命令,**不主动推送状态**,否则当前小程序会认为收到非预期数据并断开。get_status 使用轮询。
### 4.2 B1 获取设备信息
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | cmd | string,必填 | 命令 | `get_info` |
| 输出 | device_id | string | 工厂编号,后续向平台查询归属 | `KP01-020000260914` |
| 输出 | model | string | 设备型号,用于显示与注册一致性 | `ECHOEAR-S3` |
| 输出 | protocol_version | number | 明文 BLE 版本,小程序仅接受 2 | `2` |
```json
{"cmd":"get_info"}
```
```json
{"device_id":"KP01-020000260914","model":"ECHOEAR-S3","protocol_version":2}
```
### 4.3 B2 配网和绑定
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | cmd | string,必填 | 配网命令 | `configure` |
| 输入 | wifi_ssid | string,必填,1–32 UTF-8 字节 | 2.4 GHz Wi-Fi 名称 | `HomeWiFi` |
| 输入 | wifi_password | string,必填,8–63 可打印 ASCII 字符 | Wi-Fi 密码;当前不支持开放网络 | `Example123` |
| 输入 | bind_token | string,必填 | 未绑定时 43 字符 base64url 令牌;已绑定仅配网时必须是空字符串 | `服务器返回值` 或 `""` |
| 输出 | status | string | received 只表示已接收,尚未联网/绑定成功 | `received` |
| 输出 | message | string,仅失败时 | 给用户的失败说明 | `Wi-Fi 参数不正确` |
```json
{"cmd":"configure","wifi_ssid":"HomeWiFi","wifi_password":"Example123","bind_token":"<M2返回的43字符原值>"}
```
```json
{"status":"received"}
```
已绑定只换 Wi-Fi 的完整示例:
```json
{"cmd":"configure","wifi_ssid":"NewHomeWiFi","wifi_password":"Example456","bind_token":""}
```
空令牌不等于解绑,也不等于缺字段或 null。设备不需要获得微信 OpenID、手机号、家庭账号密码或账号 access_token。Wi-Fi 密码只在手机与设备之间传输,不上传平台。
设备收到后提示用户轻点屏幕,60 秒内确认才开始联网。嵌入式可以实现等价的明确确认按键,但不能直接把 received 当成确认成功。确认后把本次 Wi-Fi、令牌和新 UUID v4 确认事件 ID 原子保存,D2 的 X-Request-ID 使用此事件 ID;重试保持它和正文不变。空令牌调用 D3,返回 bound=false 时不保存新 Wi-Fi,提示重新绑定。
### 4.4 B3 查询配网状态
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | cmd | string,必填 | 查询命令 | `get_status` |
| 输出 | status | string | 当前状态,见下表 | `success` |
| 输出 | message | string,可选 | 失败/恢复提示 | `设备已解绑,请重新搜索并绑定账号` |
```json
{"cmd":"get_status"}
```
```json
{"status":"failed","message":"设备已解绑,请重新搜索并绑定账号"}
```
| status | 中文解释与设备动作 |
|---|---|
| idle | 等待 configure |
| received | 已收参数,等待设备屏幕确认 |
| wifi_connecting | 正在连接新 Wi-Fi |
| binding | 正在 D2 绑定或 D3 查询已有绑定 |
| success | 云端成功且 Wi-Fi 已持久化;可以结束配网 |
| failed | 明确失败,message 解释原因 |
小程序约每 2.5 秒轮询结果。同一任务进行中收到完全相同的 configure 返回已有状态;不同参数不替换进行中的任务。云端超时不代表绑定未执行,保留任务重试。明确 4xx(408/429 除外)停止并提示。BLE 断开不能作为成功依据。当前固件支持首次自动配网、双击触摸屏重新进入、5 分钟广播窗口;不要长按电源键作为入口,它会关机。
## 5. 小程序辅助接口(嵌入式不调用)
公共请求头:`Content-Type: application/json`,`X-Request-ID: UUID v4`,`Authorization: Mini <登录返回的access_token>`,`X-Login-Portal: customer`。access_token 已包含 customer. 前缀,不重复添加。用户登录及 getPhoneNumber 均由小程序负责。
以下示例省略公共成功信封,仅展示 data。设备状态查询和绑定令牌都应以平台为准,不能靠设备本地保存的“已绑定”标志替代。
| 接口 | 方向/字段 | 中文解释与用途 | 示例 |
|---|---|---|---|
| M1 | 输入 device_id | 工厂编号,查询当前账号是否有权操作 | `KP01-020000260914` |
| M1 | 输出 device_id / id / model / bound | 工厂编号 / 平台公开 UUID / 型号 / 是否已绑定 | `{"device_id":"KP01-020000260914","id":"f9839cde-3078-4edc-a40a-c2dbe284fad6","model":"ECHOEAR-S3","bound":false}` |
| M2 | 输入 device_id / elder_id | 工厂编号 / 选定老人公开 UUID,均由小程序明确传入 | `{"device_id":"KP01-020000260914","elder_id":"<老人UUID>"}` |
| M2 | 输出 bind_token | 32 随机字节的 base64url 无填充编码,43 字符,5 分钟有效;原样 BLE 转发,不自行计算 | `{"bind_token":"<43字符值>"}` |
| M3 | 输入 bind_token | 本次申请的非空令牌,查绑定结果 | `{"bind_token":"<43字符值>"}` |
| M3 | 输出 status / message | pending 等待、success 成功、failed 失败及说明 | `{"status":"pending"}` |
| M4 | 输入 | 无正文 | GET 请求 |
| M4 | 输出 items[] | 设备集合,含 id、binding_id、model、subject_id、subject_name、household_id、status、firmware_version | `{"items":[]}` |
| M5 | 输入 URL id / action | 平台设备 UUID / 固定 unbind | `.../devices/f9839cde-3078-4edc-a40a-c2dbe284fad6/binding-impact?action=unbind` |
| M5 | 输出 binding_id / allowed / blockers | 当前绑定版本、是否可解绑、不能解绑的原因;另有任务数量及源家庭/老人信息 | `{"binding_id":"<绑定UUID>","allowed":true,"blockers":[]}` |
| M6 | 输入 action / expected_binding_id / acknowledged / reason | 固定 unbind / M5 取得的绑定 UUID / 用户已确认 true / 解绑原因 | `{"action":"unbind","expected_binding_id":"<绑定UUID>","acknowledged":true,"reason":"小程序设备管理解除绑定"}` |
| M6 | 输出 device_id / action / previous_binding_id / changed_at | 变更设备 UUID、操作类型、旧绑定、变更时间 | `{"device_id":"<平台UUID>","action":"unbind","previous_binding_id":"<旧绑定UUID>","changed_at":"<UTC时间>"}` |
小程序在点击发送前再次调用 M1。未绑定才调用 M2,已绑定直接发空令牌。M1 返回 403 时停止,不向设备发送 Wi-Fi。M2 的 409 ALREADY_BOUND 要刷新状态,不得覆盖绑定。解绑由 M5/M6 完成,历史记录不删除,设备 Wi-Fi 不被远程擦除;设备下次 D9 会收到绑定错误。当前平台有进行中的通话等阻塞项时会拒绝解绑,不能循环强行提交。
## 6. 设备 HTTPS 公共格式与计算
### 6.1 请求头(D2–D10)
| 输入字段 | 类型 | 中文解释与用途 | 示例/计算 |
|---|---|---|---|
| Content-Type | header string | JSON 正文 | `application/json` |
| X-Device-ID | header string | 工厂编号,不是蓝牙地址或平台 UUID | `KP01-020000260914` |
| X-Key-Version | header 十进制字符串 | 平台密钥版本,无前导零 | `1` |
| X-Timestamp-Ms | header 十进制字符串 | 校时后的 Unix UTC 毫秒时间,需在服务器 ±120 秒内 | `1789372800000` |
| X-Nonce | header string | 每次 HTTP 尝试新生成 16 随机字节,base64url 无填充 → 22 字符 | 不复用;不是 boot_id |
| X-Request-ID | header string | 小写 UUID v4;业务重试策略见 6.4 | `12345678-1234-4234-8234-123456789abc` |
| Authorization | header string | 按下一节 HMAC-SHA256 计算 | `Device-HMAC-SHA256 <64位小写hex>` |
这些接口没有 URL 查询参数,不能自行添加 `?device_id=...`。不用旧的 X-Device-Timestamp、X-Device-Signature 等签名头。正文必须是 UTF-8 JSON 对象,不允许重复字段、未知字段、错误类型;普通设备接口请求正文上限 8192 字节,chat/start/end 上限 2048 字节。必须处理非 2xx 和业务 code。
### 6.2 签名算法:直接照此实现
```text
K = hex_decode(device-init.private.json 中 device_key_hex) // 32 字节,不是64字符文本
raw = UTF8(实际要发送的 JSON 字符串)
body_sha = lowercase_hex(SHA256(raw))
canonical = "DEVREQ1\n"
+ "POST\n"
+ path + "\n" // 如 /api/v1/device/ble-state;无域名、无查询串
+ factory_id + "\n"
+ decimal(key_version) + "\n"
+ decimal(timestamp_ms) + "\n"
+ nonce_base64url + "\n"
+ request_id + "\n"
+ body_sha // 最后没有换行
signature = lowercase_hex(HMAC_SHA256(K, UTF8(canonical)))
Authorization = "Device-HMAC-SHA256 " + signature
```
分隔符是真实 LF 字节 `0x0A`,不是反斜杠和 n 两个字符,不是 Windows CRLF。先序列化一次,签名和发送使用同一份 raw;空格、换行、字段顺序都会改变摘要。D3 的正文是 `{}`,不是空字符串。
base64url 无填充:普通 Base64 的 `+` 换成 `-`、`/` 换成 `_`,去掉末尾 `=`。解码时反向替换并补足到 4 的倍数。UUID v4:16 随机字节,byte[6]=(byte[6]&0x0f)|0x40,byte[8]=(byte[8]&0x3f)|0x80,再输出小写 `8-4-4-4-12` 十六进制字符串。
离线验算(以下是公开假密钥,不是新设备真实密钥):
```text
K hex = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
path = /api/v1/device/ble-state
factory_id = KP01-020000260914
key_version = 1
timestamp_ms = 1789372800000
nonce = AAECAwQFBgcICQoLDA0ODw
request_id = 12345678-1234-4234-8234-123456789abc
raw = {}
body_sha = 44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
signature = 6589d587fad96f306ed1ef839d66a9a4f8408e133a1cb65796168106e89ca66d
```
附带 `embedded-signing-reference.cjs` 可用 Node.js 执行并输出完整 canonical、请求头及 BLE 帧。固定测试时间、nonce 和示例假密钥仅用于离线算法测试,不能用于线上请求。
### 6.3 公共输出信封
```json
{"ok":true,"code":"OK","request_id":"<UUID>","server_time_ms":1789372800000,"data":{}}
```
| 输出字段 | 中文解释与用途 |
|---|---|
| ok | 业务是否成功;不能仅看 TCP/HTTP 发送成功 |
| code | 业务码,成功 OK;错误按码分支 |
| request_id | 本次请求追踪 ID |
| server_time_ms | 服务器 UTC 毫秒时间;用于聊天有效期计算 |
| data | 下面各接口的业务输出 |
| message | 错误说明,可能存在于失败响应 |
| recovery_action | 部分错误附带恢复提示;优先按 HTTP/code 实现 |
错误示例:HTTP 409,`{"ok":false,"code":"DEVICE_UNBOUND","message":"绑定错误:设备没有绑定人员","request_id":"<UUID>"}`。D1 校时成功响应例外,不带这层信封。M4–M6 的旧业务接口采用 `{request_id,data}` 成功包装,小程序自行处理,嵌入式不用兼容它。
### 6.4 重试和持久化
| 对象 | 生成/重试规则 |
|---|---|
| boot_id | 每次 MCU 真正启动生成一次,当前启动各请求保持;不是每次 HTTP 新建 |
| session_id | 每次新聊天生成一次;start 重试、10 秒续期、end 始终相同;结束后不能复用 |
| X-Request-ID | D2 确认事件、D4 注册、D6 申请、D7 结果、D8 确认的一次业务操作重试保持,正文逐字节不变;新操作新建;同 ID 不同正文返回冲突 |
| D9/D10 | 核心业务幂等按 device + session_id;每次 start/续期可新 request_id;end 建议持久化一个固定 request_id 及正文直到成功 |
| timestamp / X-Nonce | **任何 HTTP 重试都重新生成**,即使 request_id 保持;重新计算签名 |
| bind_token | 仅本次绑定使用,成功清理;过期不自行生成,回小程序重新申请 |
| 火山 SDK 注册结果 | 成功先保存 device_secret、RTCAppID、attempt_id 和待上报结果,再确认平台;断电后补报,不重复消耗注册流程 |
| 待结束聊天 | 保存 session_id、reason、sdk_stopped、end request_id;成功才删除。重启残留会话 reason=reboot,不能伪造已观察到 SDK 正常停止 |
网络/5xx/408/429 使用有上限的延迟重试;有有效租期时只用剩余时间,不能自行延长音频权限。401 先校时核验设备身份;403/409 按业务码恢复,不无限重试。任务 HTTP 不要放在蓝牙回调或 UI 回调里阻塞执行,使用独立任务,避免看门狗重启。
## 7. 设备与平台接口明细
下文输入均为 JSON body,输出为 data 内字段(D1 例外)。每个接口完整地址见第 3 节。
### D1 校时:`POST /api/v1/device/time-challenges`
唯一可用 HTTP 的设备接口,不需要 6.1 签名头,也不传 Wi-Fi/绑定令牌;响应自带工厂密钥 HMAC。设备时钟尚未设置时先做它。
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | device_id | string,必填 | 工厂编号 | `KP01-020000260914` |
| 输入 | key_version | integer,必填 | 工厂密钥版本 | `1` |
| 输入 | boot_id | UUID v4,必填 | 当前启动编号 | `<本次启动UUID>` |
| 输入 | client_nonce | string,必填 | 新 16 字节随机数 base64url,无填充 22 字符,关联请求与响应 | `<22字符值>` |
| 输出 | payload_b64 | string | base64url 编码的 JSON,不是密文 | `<编码值>` |
| 输出 | mac_hex | string | 64 小写 hex,校验返回内容 | `HMAC(K,"TIME1\n"+payload_b64)` |
| 解码输出 | device_id / boot_id / client_nonce | string | 必须与本次请求逐项相同,拒绝错设备/旧响应 | 原值 |
| 解码输出 | challenge_id | UUID v4 | 启动注册所用挑战 ID | `<服务器UUID>` |
| 解码输出 | server_time_ms | int64 | 用于设置设备 UTC 时钟 | `<当前毫秒>` |
| 解码输出 | expires_at_ms | int64 | 挑战过期时间,当前为签发后 120 秒 | `<当前毫秒+120000>` |
```json
{"device_id":"KP01-020000260914","key_version":1,"boot_id":"12345678-1234-4234-8234-123456789abc","client_nonce":"AAECAwQFBgcICQoLDA0ODw"}
```
```json
{"payload_b64":"<base64url JSON>","mac_hex":"<64位小写hex>"}
```
计算 `expected=hex_lower(HMAC_SHA256(K,UTF8("TIME1\n"+payload_b64)))`,与 mac_hex 比较成功,再解码 JSON,核对回传字段。使用单调计时判断整个请求往返小于 15 秒,不能用未校准的墙上时钟判断。正确后设置 UTC,保存 challenge_id 并在 120 秒内 register。这里 client_nonce 是校时用,不是已取消的 BLE 握手。
### D2 兑现绑定:`POST /api/v1/device/ble-bind`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | bind_token | string,必填,43 字符 | 小程序 BLE 传来的非空令牌,原样传递 | `{"bind_token":"<43字符原值>"}` |
| 输出 | status | string | 绑定是否完成;成功固定 success | `{"status":"success"}` |
设备完成物理确认、联网、校时后调用。X-Request-ID 使用已保存的本次确认事件 UUID。无需发送 Wi-Fi、老人 ID 或账号资料,令牌已包含服务器端关联。平台验证令牌所属设备/家庭/老人、有效期及绑定代次;重试不会重复绑定。不接受空令牌。
### D3 查询绑定:`POST /api/v1/device/ble-state`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | 无业务字段 | 空对象,必填 | 查询签名头对应的设备,不传其他字段 | `{}` |
| 输出 | bound | boolean | 当前是否仍绑定;false 要回到首次绑定流程 | `{"bound":false}` |
收到空 bind_token 时调用。bound=true 才保存本次新 Wi-Fi,bound=false 不改变归属、不保存新配置。这里为 true 仅表示已绑定,不代表 SDK 已注册或智能体已就绪。
### D4 每次启动注册:`POST /api/v1/device/register`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | challenge_id | UUID v4,必填 | D1 返回值,未过期、未消费 | `<D1值>` |
| 输入 | boot_id | UUID v4,必填 | 与 D1 同一启动编号 | `<本次启动UUID>` |
| 输入 | hardware_model | string,必填 | 必须与生产登记型号相同 | `ECHOEAR-S3` |
| 输入 | hardware_revision | string,必填 | 必须与生产登记硬件版本相同 | `V1.2` |
| 输入 | firmware_version | string,必填,1–32 字节 | 自己的真实固件版本 | `1.5.0-elder-agent.2` |
| 输入 | sdk_version | string,必填,1–64 字节 | 使用的真实语音 SDK 版本 | `2.0.0` |
| 输出 | contract_version | string | 当前平台接入契约标识 | `ONBOARDING-1.1` |
| 输出 | state | object | 当前绑定/激活状态,见下表 | `<状态对象>` |
```json
{"challenge_id":"<D1挑战UUID>","boot_id":"<启动UUID>","hardware_model":"ECHOEAR-S3","hardware_revision":"V1.2","firmware_version":"1.5.0-elder-agent.2","sdk_version":"2.0.0"}
```
```json
{"contract_version":"ONBOARDING-1.1","state":{"device_id":"KP01-020000260914","device_revision":3,"binding_generation":0,"binding_state":"UNBOUND","activation_state":"NOT_STARTED","state_hash":"<64位摘要>"}}
```
示例 revision 仅示意,以真实返回为准。新 boot_id 会关闭旧启动的设置状态,正在执行但未确认的 SDK 注册可能转为待对账;不要每次请求都更换 boot_id。
#### 公共 state 输出
| 输出字段 | 中文解释与用途 |
|---|---|
| device_id | 工厂编号 |
| device_revision | 平台设备状态版本,状态变化会增加;不是固件版本 |
| binding_generation | 绑定代次,绑定/解绑会变化;设备只能保存响应原值,不自行 +1 |
| binding_state | UNBOUND 未绑定、CLAIM_PENDING 旧流程待确认、BOUND 已绑定 |
| activation_state | NOT_STARTED/RESERVED/CONTACT_POSSIBLE/REGISTERED_OBSERVED/READY/RECONCILE_REQUIRED/BLOCKED 等激活任务状态;不是库存的 CLAIMABLE 生命周期 |
| state_hash | SHA256 状态摘要;当前最小流程无需单独 state-acks,可保存和核对 |
如需验算 state_hash,使用:
```text
hex_lower(SHA256(UTF8("STATE1\n" + device_id + "\n" + decimal(device_revision)
+ "\n" + decimal(binding_generation) + "\n" + binding_state + "\n" + activation_state)))
```
### D5 获取语音运行配置:`POST /api/v1/device/voice-runtime`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | boot_id | UUID v4,必填 | 已完成 D4 的当前启动编号 | `{"boot_id":"<启动UUID>"}` |
| 输出 | state | object | 当前绑定代次、激活状态 | 见 D4 |
| 输出 | command | object 或 null | 当前需应用的激活配置;null 不可继续当作有效配置 | 见下例 |
| 输出 | command.command_id | UUID | 激活任务 ID | `<activation_id>` |
| 输出 | command.type | string | 配置命令 | `APPLY_ACTIVATION` |
| 输出 | command.expires_at_ms | int64 | 配置有效期 | `<毫秒>` |
| 输出 | command.payload.config_b64 | string | base64url JSON 配置 | `<编码值>` |
| 输出 | command.payload.config_mac | string | 配置 HMAC,校验公式如下 | `<64位hex>` |
| 输出 | call_contract | string | 现有兼容字段,可能仍为 CALL-MEMORY-1.1;本实现明确使用 D9/D10,不按此字段切回旧 calls 路径 | `CALL-MEMORY-1.1` |
```json
{"state":{"binding_generation":1,"binding_state":"BOUND","activation_state":"RESERVED"},"command":{"command_id":"<激活UUID>","type":"APPLY_ACTIVATION","expires_at_ms":1789459200000,"payload":{"config_b64":"<编码配置>","config_mac":"<64位hex>"}},"call_contract":"CALL-MEMORY-1.1"}
```
state 在这里省略了 D4 已列出的通用字段。先比较:
```text
expected_config_mac = hex_lower(HMAC_SHA256(K, UTF8("CFG1\n" + config_b64)))
config = JSON_parse(base64url_decode(config_b64))
```
此处是 **Base64 编码 + 签名,不是应用层加密**;实际通过 HTTPS 传输。解码后的字段:
| 输出字段 | 中文解释与用途 | 示例/处理 |
|---|---|---|
| contract_version | 当前注册配置契约 | ONBOARDING-1.1 |
| device_id | 应与烧录工厂编号一致 | 不一致拒绝 |
| activation_id | 本次绑定的激活任务 ID,后续 D6–D8 使用 | 原样保存 |
| config_version | 当前配置版本,D6/D8 使用 | 原样保存,不自行递增 |
| binding_generation | 必须与 state.binding_generation 一致 | 不一致重新拉取 |
| expires_at_ms | 24 小时配置有效期,以实际返回为准 | 过期重新拉取 |
| iot.instance_id | 火山实例 ID | SDK 初始化/注册使用 |
| iot.product_key | 火山产品 Key | SDK 初始化/注册使用 |
| iot.product_secret | 当前实现确实下发的火山产品注册密钥 | 仅动态注册时使用,不是平台设备密钥;不打印、不发给小程序 |
| iot.device_name | 平台分配的火山设备名称 | 原样使用,不能自行替换为 MAC |
| bot_id | 产品默认激活配置中的 bot | **聊天必须用 D9 返回的老人专属 bot_id,不要用这个默认值覆盖它** |
未绑定返回 409 DEVICE_UNBOUND。已经有火山 device_secret 时复用本地凭据,检查实例/产品/名称是否匹配;不因为重启或换绑老人就重新动态注册。若注册状态不确定(RECONCILE_REQUIRED),停止重复注册,交给平台核对。
### D6 SDK 注册执行许可:`POST /api/v1/device/activation-attempts`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | activation_id | UUID v4,必填 | D5 激活任务 | `<D5值>` |
| 输入 | config_version | integer,必填,≥1 | D5 配置版本 | `1` |
| 输入 | attempt_id | UUID v4,必填 | 设备为一次 SDK 注册生成并保存的编号 | `<新UUID>` |
| 输出 | allowed | boolean | 是否允许这次外部 SDK 操作 | `true` |
| 输出 | attempt_id | UUID | 对应本次操作 | 原值 |
| 输出 | permit_expires_at_ms | int64 | 必须在许可有效期内发起注册,当前 30 秒 | `<毫秒>` |
| 输出 | state | object | 平台激活状态 | CONTACT_POSSIBLE |
```json
{"activation_id":"<D5激活UUID>","config_version":1,"attempt_id":"<新UUID>"}
```
```json
{"allowed":true,"attempt_id":"<同一UUID>","permit_expires_at_ms":1789372830000,"state":{"activation_state":"CONTACT_POSSIBLE"}}
```
这一步只在确实需要首次动态注册时执行,不是每次聊天都调用。拿到许可并保存“可能已经发起注册”记录后才调用 V1。不要在许可不确定/超时后换个 attempt_id 再次注册;先恢复原结果,防止重复注册。当前直聊首次通话可完成激活,不用另外实现旧 CONVERSATION_TEST 会话。
### D7 SDK 注册结果:`POST /api/v1/device/activation-results`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | activation_id | UUID v4,必填 | 当前激活任务 | `<D5值>` |
| 输入 | attempt_id | UUID v4,必填 | D6 使用的原编号 | `<D6值>` |
| 输入 | stage | string,必填 | 当前最小流程固定 SDK_REGISTER | `SDK_REGISTER` |
| 输入 | sdk_result | integer,必填 | SDK 返回码,成功必须 0 | `0` |
| 输入 | outcome | string,必填 | SUCCESS_OBSERVED 确认成功、DEFINITE_FAILURE 确认失败、UNKNOWN 无法确认 | `SUCCESS_OBSERVED` |
| 输出 | activation_id | UUID | 对应激活任务 | 原值 |
| 输出 | state | object | 成功通常 REGISTERED_OBSERVED | 见 D4 |
| 输出 | reconcile_required | boolean | true 表示需平台核对,不能再盲目注册 | `false` |
```json
{"activation_id":"<D5值>","attempt_id":"<D6值>","stage":"SDK_REGISTER","sdk_result":0,"outcome":"SUCCESS_OBSERVED"}
```
```json
{"activation_id":"<D5值>","state":{"activation_state":"REGISTERED_OBSERVED"},"reconcile_required":false}
```
只有真的拿到并保存火山 device_secret 和 RTCAppID 才报成功。超时、断电无法确认归 UNKNOWN,不等于 DEFINITE_FAILURE。该接口不上传火山密钥。结果重试保持同一正文和结果 request_id;同一 attempt 已保存不同结果时不能覆盖。
### D8 SDK 初始化确认:`POST /api/v1/device/activation-acks`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | activation_id | UUID v4,必填 | D5 任务 ID | `<原值>` |
| 输入 | config_version | integer,必填,≥1 | 已保存的当前配置版本 | `1` |
| 输入 | local_generation | integer,必填,≥0 | 本地保存配置的代次;同一启动不得倒退。现有固件直接用 config_version | `1` |
| 输出 | accepted | boolean | 平台已接受保存/初始化回执 | `true` |
| 输出 | state | object | 当前状态 | REGISTERED_OBSERVED 或 READY |
```json
{"activation_id":"<D5值>","config_version":1,"local_generation":1}
```
```json
{"accepted":true,"state":{"activation_state":"REGISTERED_OBSERVED"}}
```
在 D7 成功之后、RTC SDK 初始化成功之后调用。没有成功回执不能开始聊天。普通每次开机已存在火山凭据时仍需要 D4→D5→SDK 初始化→D8。
### D9 开始聊天/续期:`POST /api/v1/device/chat/start`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | device_id | string,本实现必传 | 与 X-Device-ID 一致;服务端兼容旧固件可省略,但新实现不要省 | `KP01-020000260914` |
| 输入 | session_id | UUID,必填,建议 UUID v4 | 一次聊天的唯一编号;重试和续期保持 | `<新聊天UUID>` |
| 输入 | boot_id | UUID,必填 | 已完成 D4 的当前启动编号 | `<启动UUID>` |
| 输出 | session_id | UUID | 必须对应本次聊天 | 原值 |
| 输出 | bot_id | string | 当前绑定老人的专属智能体 | `bot...` |
| 输出 | params | object | 整体交给 SDK;不要只挑一两个字段 | 下表 |
| 输出 | lease_expires_at_ms | int64 | 当前音频运行租期截止,最多 45 秒 | `<毫秒>` |
| 输出 | expires_at_ms | int64 | 本通话总截止,最多 1 小时,也可能受配额缩短 | `<毫秒>` |
```json
{"device_id":"KP01-020000260914","session_id":"<本次聊天UUID>","boot_id":"<启动UUID>"}
```
```json
{"ok":true,"code":"OK","request_id":"<请求UUID>","server_time_ms":1789372800000,"data":{"session_id":"<本次聊天UUID>","bot_id":"<老人bot_id>","params":{"PlatformTaskID":"<本次聊天UUID>","AgentConfig":{"WelcomeMessage":"你好,今天想聊些什么?","EnableConversationStateCallback":false,"ServerMessageURLForRTS":"https://121.41.46.238/callbacks/v1/volcengine/chat?session_id=<本次聊天UUID>","ServerMessageSignatureForRTS":"<平台生成的本次回调签名>"}},"lease_expires_at_ms":1789372845000,"expires_at_ms":1789376400000}}
```
| params 输出字段 | 中文解释与用途 | 设备动作 |
|---|---|---|
| PlatformTaskID | 本次平台会话关联号 | SDK 取它作为火山 task_id;不能另外随机生成 |
| AgentConfig.WelcomeMessage | 欢迎语 | 原样传给 SDK |
| AgentConfig.EnableConversationStateCallback | 是否开通供应商会话状态回调,当前 false | 原样传递;聊天结束由设备上报 |
| AgentConfig.ServerMessageURLForRTS | 本次字幕回调 URL,含 session_id | 原样传火山;不是 Function calling URL |
| AgentConfig.ServerMessageSignatureForRTS | 平台生成的回调签名值 | 原样传火山;设备不需要计算,不需要平台回调主密钥 |
该字段由平台计算为 `base64url_no_padding(HMAC_SHA256(callbackSecret, UTF8("volc-subtitle-v1\ndirect:" + session_id)))`。callbackSecret 只留服务器;这条公式供接口理解,嵌入式不需要获得该密钥,也不能改用工厂设备密钥计算。设备仅复制 D9 实际返回值。
平台每次新聊天查 `设备→绑定老人→老人智能体`,未绑定 HTTP 409 DEVICE_UNBOUND;未注册 SDK/未 D8 确认 DEVICE_NOT_READY;智能体未创建成功 VOICE_AGENT_NOT_READY。同老人/设备有另一通活动聊天返回 CHAT_ALREADY_ACTIVE。S2SConfig、模型、记忆由平台配置到该老人智能体,本接口不再把它们交给设备配置。
**聊天中每 10 秒重复调用 D9**,同 session_id/boot_id,返回新的 lease_expires_at_ms;这不是重新创建聊天。第一次把 params 交给 SDK,续期不重进房。超过租期或明确绑定/授权错误,关闭本地采集、播放及 SDK,然后 D10。
设备使用单调计时避免校时跳变:
```text
request_started = monotonic_ms()
收到响应后 elapsed = monotonic_ms() - request_started
remaining = data.lease_expires_at_ms - envelope.server_time_ms - elapsed
要求 0 < remaining <= 45000
local_deadline = monotonic_ms() + remaining
```
剩余时间非正则拒绝这次授权。expires_at_ms <= server_time_ms 也拒绝。网络失败仅沿用上一次仍有效的 local_deadline,不能按“现在+45秒”自续。总期限到达需结束,不复用旧 session_id。
### D10 聊天结束:`POST /api/v1/device/chat/end`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | device_id | string,本实现必传 | 与请求头工厂编号一致 | `KP01-020000260914` |
| 输入 | session_id | UUID,必填 | D9 使用的同一个聊天编号 | `<原值>` |
| 输入 | reason | string,必填 | 结束原因:hangup、start_failed、disconnected、reboot、timeout | `hangup` |
| 输入 | sdk_stopped | boolean,本实现明确传 | 已观察到本地 SDK 停止成功才 true;不是“我准备停止” | `true` |
| 输出 | session_id | UUID | 对应会话 | 原值 |
| 输出 | ended | boolean | 平台已接收结束;不证明远端所有媒体均已被平台观测停止 | `true` |
```json
{"device_id":"KP01-020000260914","session_id":"<本次聊天UUID>","reason":"hangup","sdk_stopped":true}
```
```json
{"session_id":"<本次聊天UUID>","ended":true}
```
先停本地音频,再停止 SDK,最后上报。网络失败保存补报;重启时未完成会话使用 reason=reboot,sdk_stopped=false(未亲眼观察到旧 SDK 正常停止)。即使已解绑,仍可用有效工厂身份给旧 session_id 补报结束。已结束的 session_id 再 start 返回 CHAT_ENDED;start 超时也要尝试 end,避免服务器已创建而设备不知道。
## 8. 设备直连火山 SDK 的必要参数
本节按项目内已验证的火山硬件 SDK 实现说明,不是让嵌入式自行实现整套 RTC。初次注册有外部 License/配额影响;本交接设备尚未执行此步骤。
### 8.1 V1 DynamicRegister:首次获取火山设备凭据
SDK 函数 `volc_device_register(&iot_info, &device_secret)` 使用 D5 的 instance_id/product_key/product_secret/device_name。收到 D6 许可后才执行。
| 方向 | 字段 | 中文解释与用途 | 示例/来源 |
|---|---|---|---|
| 输入 | InstanceID | 火山实例 ID,注意大小写 | D5 iot.instance_id |
| 输入 | product_key | 产品标识 | D5 iot.product_key |
| 输入 | device_name | 火山设备名 | D5 iot.device_name |
| 输入 | random_num | 本次签名随机字段;当前 SDK 取 int32(timestamp_ms),可能为负数 | 原始十进制值参与签名 |
| 输入 | timestamp | UTC 毫秒数字,不是秒 | 已校时设备时钟 |
| 输入 | auth_type | 注册使用产品密钥,固定数字 1 | `1` |
| 输入 | signature | 火山格式 HMAC 签名,见 8.3 | 普通 Base64,非 hex |
| 输出 | Result.payload | 加密的火山设备密钥 | SDK 解密后得到 device_secret |
| 输出 | Result.RTCAppID | RTC 应用 ID | 持久化,初始化 RTC 引擎 |
| 输出 | ResponseMetadata.Error | 火山错误信息(失败时) | SDK 检查,不把错误包当成功 |
```json
{"InstanceID":"<实例>","product_key":"<产品>","device_name":"<火山设备名>","random_num":123456,"timestamp":1789372800000,"auth_type":1,"signature":"<普通Base64签名>"}
```
```json
{"Result":{"payload":"<加密Base64内容>","RTCAppID":"<RTC应用ID>"}}
```
SDK 注册解码:`ciphertext=普通Base64_decode(Result.payload)`,AES-128-CBC 密钥为 **product_secret 字符串前16字节**,IV 也是前16字节,解密后校验并去除 PKCS#7 填充,得到火山 device_secret 字符串。不是用平台的 32 字节 K,也不是将 product_secret 按 hex 解码。项目现有 `volc_aes_decode(product_secret,payload,true)` 已实现,优先复用。
### 8.2 V2 GetRTCConfig:每次开聊获取房间
| 方向 | 字段 | 中文解释与用途 | 示例/来源 |
|---|---|---|---|
| 输入 | InstanceID / product_key / device_name | 此设备火山身份 | 首次注册配置的原值 |
| 输入 | random_num / timestamp / signature | 用火山 device_secret 签名,auth_type 按 0 参与计算;当前此请求 body 不额外加 auth_type | 见 8.3 |
| 输入 | bot_id | 此次老人的智能体 | **D9 bot_id** |
| 输入 | audio_codec | RTC 编码枚举,必须与 SDK 引擎和实际上传编码一致 | 当前 RTCLite `AUDIO_CODEC_TYPE_OPUS=1`,本项目传数字 `1` |
| 输入 | task_id | 此次通话关联号 | D9 params.PlatformTaskID |
| 输入 | agent_config | 本次欢迎语和字幕回调配置 | D9 params.AgentConfig,转换为 body 小写字段 agent_config |
| 输入 | config | 可选的覆盖配置 | 当前老人智能体方案 **省略整个 config 字段**,不要传空 S2SConfig 覆盖后台配置 |
| 输出 | Result.RoomID | RTC 房间 ID | SDK 加入该房间 |
| 输出 | Result.UserID | 本设备在房间内的用户 ID | 原样传 SDK |
| 输出 | Result.Token | RTC 临时凭据 | 原样传 SDK,不当永久设备密钥 |
| 输出 | Result.TaskID | 火山任务 ID | SDK 保存管理本次会话 |
```json
{"InstanceID":"<实例>","product_key":"<产品>","device_name":"<火山设备名>","random_num":123456,"timestamp":1789372800000,"signature":"<签名>","bot_id":"<D9老人bot_id>","audio_codec":1,"task_id":"<D9 PlatformTaskID>","agent_config":{"WelcomeMessage":"你好,今天想聊些什么?","EnableConversationStateCallback":false,"ServerMessageURLForRTS":"<D9回调URL>","ServerMessageSignatureForRTS":"<D9签名原值>"}}
```
上例 audio_codec=1 对应当前工程 RTCLite 的 OPUS 枚举;换 SDK 时需重新核对该层枚举,不能只改编码标志而不改实际音频编码。输出示例:`{"Result":{"RoomID":"<房间>","UserID":"<本机用户>","Token":"<房间token>","TaskID":"<任务>"}}`。
最容易遗漏:`params.AgentConfig` 转为 `agent_config`,`params.PlatformTaskID` 转为 `task_id`。如果换用未包含这些修改的原始 SDK,可能能通话但回调对不上。参考包提供本项目实际修改过的 SDK 对接源文件,不是可以脱离完整 SDK 独立编译的库。
### 8.3 火山签名计算(与平台 DEVREQ1 不同)
```text
canonical = "auth_type=" + decimal(auth_type)
+ "&device_name=" + device_name
+ "&random_num=" + decimal(random_num)
+ "&product_key=" + product_key
+ "&timestamp=" + decimal(timestamp_ms)
signature = STANDARD_BASE64(HMAC_SHA256(UTF8(secret_string), UTF8(canonical)))
```
顺序必须与上面一致(random_num 在 product_key 前),没有末尾换行。注册 auth_type=1、secret_string=product_secret;RTC 配置 auth_type=0、secret_string=火山 device_secret。Base64 保留 `+ / =`,不要使用平台请求的 lowercase hex 或 base64url。实例 ID 在 JSON 中发送,不在此 RTC 签名原文中;不要混用旧 WS 的追加 instance_id 算法。
### 8.4 音频与 SDK 停止
当前实机链路使用 high_quality/RTC;应用侧麦克风/扬声器为 **16 kHz、单声道、16-bit signed little-endian PCM**。20 ms PCM 一帧为 `16000×0.020×1×2 = 640` 字节;当前 RTC 上行在封装内编码 Opus(24 kbps),下行 Opus 解码后播放。不能把 PCM 字节标成 Opus 发送,也不能把 Opus 压缩数据直接送 DAC。
优先用完整工程中的音频/RTC 封装。通话停止时关闭采集和播放,调用 SDK stop/leave room;用真实返回结果设置 sdk_stopped,不能仅设置一个本地布尔值假装 SDK 已停止。
## 9. C1 火山聊天记录回调
这是 **火山→服务器**,不需要嵌入式上传文字、生成字幕签名或连接记忆数据库。设备责任只有把 D9 的回调字段交给火山 SDK。
| 方向 | 字段 | 中文解释与用途 | 示例 |
|---|---|---|---|
| 输入 URL | session_id | 平台本次聊天编号 | D9 session_id |
| 输入 body | message | 火山字幕包的普通 Base64 | `<Base64>` |
| 输入 body | signature | 原样回传本次 ServerMessageSignatureForRTS | `<平台给出的值>` |
| 解码字段 | type | 字幕消息类型 | subtitle |
| 解码字段 | data[].userId | 发言方,input=用户,output 或 RTC bot 用户=助手 | input |
| 解码字段 | data[].roundId / sequence | 轮次/片段序号,用于去重 | 1 / 1 |
| 解码字段 | data[].text | 发言文本 | 今天心情不错 |
| 解码字段 | data[].definite | 是否确定稿,平台只保存确定稿 | true |
| 解码字段 | data[].paragraph | 供应商段落标记 | true |
| 输出 | HTTP 200 + text/plain `ok` | 已处理回调 | `ok` |
```json
{"message":"<以下subtitle JSON的普通Base64>","signature":"<本次ServerMessageSignatureForRTS>"}
```
```json
{"type":"subtitle","data":[{"userId":"input","roundId":1,"sequence":1,"text":"今天心情不错","definite":true,"paragraph":true}]}
```
平台也支持供应商带 subv 帧头的字幕包,嵌入式不需要解析或重编码。文字写入本次会话的 call_utterance,重复回调去重,迟到回调仍归旧会话。**文字保存要求平台已获得该老人 transcript_storage 授权**;仅绑定的默认配置不自动开通记录存储。没有此授权时回调可能返回 403,设备不能据此改走私自上传。首次完整激活还依赖正常挂断和实际助手字幕证据,联调前由平台确认授权与老人智能体就绪。
## 10. 异常处理与验收清单
| 错误/场景 | 嵌入式处理 |
|---|---|
| DEVICE_AUTHENTICATION_FAILED / 401 | 核对工厂编号、32字节密钥解码、签名 LF、正文和校时;不可降级旧签名协议 |
| DEVICE_UNBOUND / 409 | 停止/禁止聊天,提示设备未绑定人员,进入配网绑定入口 |
| DEVICE_BINDING_CHANGED / BINDING_GENERATION_CONFLICT | 停止使用旧参数,同步当前启动/绑定,必要时重新走 D4/D5/D8 |
| DEVICE_NOT_READY | 恢复 D5–D8,不能绕过注册/保存确认 |
| VOICE_AGENT_NOT_READY | 平台修复老人智能体,设备不自行改用公共默认 bot |
| CHAT_ALREADY_ACTIVE | 清理/结束本设备旧会话,不并发制造新 session_id |
| CHAT_ENDED / 总期限或租期到期 | 停音频、停 SDK、D10;再次聊天用新 session_id |
| VOICE_CHAT_CONSENT_REQUIRED / MEMORY_CONSENT_REVOKED | 停止使用旧授权,交给平台/用户处理 |
| RECONCILE_REQUIRED / ATTEMPT_RECONCILIATION_REQUIRED | 保留注册凭据和结果,平台核对,不循环动态注册 |
| 429 / 配额不足 | 提示并退避,不能高速循环重试 |
| BLE received 后无 success | 查询 B3/M3;不能因为 BLE 断开就认定成功 |
| 解绑后再换 Wi-Fi | 空 bind_token + D3 得到 false,转首次绑定流程 |
| 断电重启 | boot_id 更新;恢复未完成绑定、注册结果及结束上报;工厂密钥不擦除 |
验收项目:
1. 小程序能发现 voicelink-ECHOEAR-S3-00260914,B1 型号和编号一致,协议为数字 2;UUID 正确的旧名称、自定义名称也能显示。
2. 离线 BLE 帧、DEVREQ1 测试向量完全一致,包括中文 SSID 的 UTF-8 字节长度。
3. 未绑定时配网令牌非空,设备确认后绑定成功;已绑定只配网发送空令牌,归属不改变。
4. D1 验签和 D4 注册通过;错误密钥/型号/硬件版本被拒绝。
5. 首次火山注册后凭据掉电保留,连续重启不重复注册;未确认结果恢复正确。
6. 连续两次聊天各用不同 session_id;同次每 10 秒续期;音频双向正常。
7. 文字由火山回调入平台,人物/设备/会话对应正确;设备无文字上传队列。
8. 正常挂断、断网、重启都能补报 D10;结束后不继续采集或播放。
9. 小程序解绑后,再 start 返回 DEVICE_UNBOUND,不再用旧 bot_id 直接开聊。
### 本次交付已验证与未验证
已创建并放行库存设备;使用私有初始化密钥在真实线上验证 TIME1 校时 HMAC、D4 注册 HTTP 200、D3 bound=false、D9 返回 HTTP 409 / DEVICE_UNBOUND。测试注册固件标记为 handoff-probe-1,真实硬件下一次 D4 会报告自己的固件版本。未绑定任何老人,未发起火山动态注册,未修改原有 EchoEar。
尚未把新身份烧录到实际硬件;该新设备的 BLE、Wi-Fi、音频及完整火山聊天需要嵌入式实现后按上述清单验收。不要把接口连通测试等同于新硬件整机验收。
## 11. 交付文件和代码依据
| 文件 | 用途 |
|---|---|
| 本文 `.md` / 同内容 `.html` | 可转发给工程师的接口、字段、算法、示例说明 |
| `device-init.private.json` | 只给本设备的烧录信息,含真实设备密钥;受控交接,不提交公共仓库 |
| `embedded-signing-reference.cjs` | 无第三方依赖的 JS 签名和 BLE 帧参考,Node.js 18+ |
| `test-vectors.json` | 离线公开假密钥测试向量,与真实设备密钥无关 |
| `reference/` | 当前固件核心通信源文件快照,辅助 C 实现;不包含完整 BSP/SDK/构建依赖 |
运行参考:`node embedded-signing-reference.cjs` 仅输出离线向量。`node embedded-signing-reference.cjs --probe device-init.private.json` 会对**这台未绑定联调设备**执行线上校时/注册/未绑定验证,不绑定、不注册火山;实物已绑定后不要再执行该 probe,避免创建新 boot_id 干扰实物。
代码依据:平台 `internal/onboarding/{auth,crypto,setup,ble,voice_runtime,device_activation,service}.go`、`internal/callmemory/direct.go`;固件 `main/{ble_cloud,ble_setup,call_client}.c`;小程序 `src/ble/client.js`;火山适配 `volc_device_manager.c`、`volc_auth.c`、`volc_rtc.c`。当前服务端事实优先于历史兼容文档,后续接口变更应同步更新本文版本。