feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+737
View File
@@ -0,0 +1,737 @@
# 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`。当前服务端事实优先于历史兼容文档,后续接口变更应同步更新本文版本。