Files
story/docs/embedded-device-handoff-20260914.md
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
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.
2026-10-08 10:06:44 +08:00

54 KiB
Raw Permalink Blame 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,这不改变编号本身。

帧示例(可直接比对十六进制):

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
{"cmd":"get_info"}
{"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 参数不正确
{"cmd":"configure","wifi_ssid":"HomeWiFi","wifi_password":"Example123","bind_token":"<M2返回的43字符原值>"}
{"status":"received"}

已绑定只换 Wi-Fi 的完整示例:

{"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,可选 失败/恢复提示 设备已解绑,请重新搜索并绑定账号
{"cmd":"get_status"}
{"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 签名算法:直接照此实现

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 十六进制字符串。

离线验算(以下是公开假密钥,不是新设备真实密钥):

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 公共输出信封

{"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>
{"device_id":"KP01-020000260914","key_version":1,"boot_id":"12345678-1234-4234-8234-123456789abc","client_nonce":"AAECAwQFBgcICQoLDA0ODw"}
{"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 当前绑定/激活状态,见下表 <状态对象>
{"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"}
{"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,使用:

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
{"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 已列出的通用字段。先比较:

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
{"activation_id":"<D5激活UUID>","config_version":1,"attempt_id":"<新UUID>"}
{"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
{"activation_id":"<D5值>","attempt_id":"<D6值>","stage":"SDK_REGISTER","sdk_result":0,"outcome":"SUCCESS_OBSERVED"}
{"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
{"activation_id":"<D5值>","config_version":1,"local_generation":1}
{"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 小时,也可能受配额缩短 <毫秒>
{"device_id":"KP01-020000260914","session_id":"<本次聊天UUID>","boot_id":"<启动UUID>"}
{"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。

设备使用单调计时避免校时跳变:

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
{"device_id":"KP01-020000260914","session_id":"<本次聊天UUID>","reason":"hangup","sdk_stopped":true}
{"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 检查,不把错误包当成功
{"InstanceID":"<实例>","product_key":"<产品>","device_name":"<火山设备名>","random_num":123456,"timestamp":1789372800000,"auth_type":1,"signature":"<普通Base64签名>"}
{"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 保存管理本次会话
{"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 不同)

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
{"message":"<以下subtitle JSON的普通Base64>","signature":"<本次ServerMessageSignatureForRTS>"}
{"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。当前服务端事实优先于历史兼容文档,后续接口变更应同步更新本文版本。