649 lines
29 KiB
Markdown
649 lines
29 KiB
Markdown
# VoiceLink 设备接口文档(蓝牙、聊天、MQTT、OTA)
|
||||
|
|
|
|||
|
|
> 2026-09-18 更新:小程序支持先添加设备到账号、后创建家庭和老人。设备端 BLE 与 ble-bind 的五个火山字段保持不变,具体变化见 [账号优先流程](device-account-first-20260918.md)。
|
|||
|
|
|
|||
|
|
2026-09-15 · 设备接口专用版 · 给嵌入式开发人员
|
|||
|
|
|
|||
|
|
**流程:小程序连接蓝牙 → 下发 Wi-Fi 和绑定令牌 → 设备联网绑定 → 每次聊天向平台拿参数 → 设备直连火山 → 设备上报结束。**
|
|||
|
|
|
|||
|
|
平台地址:`https://82.157.201.114`。下文设备编号统一使用本次已登记的联调设备 `KP01-020000260914`。
|
|||
|
|
|
|||
|
|
本文只包含设备直接参与的蓝牙、HTTP 和 MQTT 通信。第 1–7 节为初始化信息、配网绑定和聊天,第 8–10 节为心跳和 OTA;附录为设备 HTTP 请求格式、开机初始化和 C 计算函数。聊天继续按每通 `session_id` 关联回调。
|
|||
|
|
|
|||
|
|
这是一台独立联调设备的测试身份,型号沿用平台登记的 ECHOEAR-S3,不代表最终硬件型号。最终实物需分别在平台登记,不要把同一身份复制烧录到多台设备。本文的版本号、时间戳、任务 ID 和下载链接均为格式示例,运行时使用真实返回值。
|
|||
|
|
|
|||
|
|
蓝牙名称采用新约定 `vl-型号-编号后8位`,由嵌入式实现;小程序已经支持。
|
|||
|
|
|
|||
|
|
## 1. 设备需要烧录的字段
|
|||
|
|
|
|||
|
|
| 字段 | 本次设备填写值 | 用途 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| factory_id | KP01-020000260914 | 设备唯一编号;接口里叫 device_id |
|
|||
|
|
| model | ECHOEAR-S3 | 设备型号 |
|
|||
|
|
| origin | https://82.157.201.114 | 平台地址 |
|
|||
|
|
| device_key_hex | 平台生成的 64 位十六进制字符串,烧录时解码成 device_key 的 32 字节 | 当前平台请求签名用的设备密钥 |
|
|||
|
|
| key_version | 1 | 设备密钥版本 |
|
|||
|
|
|
|||
|
|
实际烧录值见同目录 [device-init.private.json](device-init.private.json)。这是**一台联调设备**的身份,不要复制到多台设备。
|
|||
|
|
|
|||
|
|
烧录交接只取以下 5 个字段,私有文件中的其他平台管理字段不用烧录:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"factory_id": "KP01-020000260914",
|
|||
|
|
"model": "ECHOEAR-S3",
|
|||
|
|
"origin": "https://82.157.201.114",
|
|||
|
|
"device_key_hex": "<平台生成的本设备密钥>",
|
|||
|
|
"key_version": 1
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
MQTT 配置联网后向平台获取,不新增每台设备的烧录字段。固件自身带实际 `firmware_version` 和 `version_code`,随每次固件发布更新;未对应平台已发布固件时顺序号填 0。建议 firmware_version 使用简短的版本名称。hardware_revision 仅是平台库存信息,新设备接口无需烧录或上报它。
|
|||
|
|
|
|||
|
|
不需要烧录:Wi-Fi、账号、老人 ID、智能体 ID、火山产品密钥。Wi-Fi 和绑定令牌由小程序发送;老人和智能体的关系由平台保存。火山设备注册由后端执行,设备只保存后端下发的专属语音凭据。
|
|||
|
|
|
|||
|
|
## 2. 蓝牙协议与名称约定
|
|||
|
|
|
|||
|
|
| 项目 | 固定值 / 约定 |
|
|||
|
|
|---|---|
|
|||
|
|
| 协议 | BLE GATT,明文 UTF-8 JSON |
|
|||
|
|
| Service ID | 8e7a2000-6f4b-4a2d-9c31-7d824f5e6100 |
|
|||
|
|
| RX 特征值 | 8e7a2001-6f4b-4a2d-9c31-7d824f5e6100,Write;小程序发给设备 |
|
|||
|
|
| TX 特征值 | 8e7a2002-6f4b-4a2d-9c31-7d824f5e6100,Indicate;设备回复小程序,提供 CCCD |
|
|||
|
|
| 蓝牙名称 | vl-型号-设备编号后8位 |
|
|||
|
|
| 本次设备名称 | vl-ECHOEAR-S3-00260914 |
|
|||
|
|
|
|||
|
|
UUID 写在程序里,所有同协议设备相同。设备开启可连接广播,Service ID 放广播包,名称单独放扫描响应包。小程序按 Service ID 搜索,名称只用于显示,旧名称也能连接。
|
|||
|
|
|
|||
|
|
名称最多 29 个 UTF-8 字节,因此 `vl-` 后的型号最多占 17 字节;尽量用英文数字,完整型号通过下一节返回。
|
|||
|
|
|
|||
|
|
**以下所有蓝牙 JSON 都按同一种方式传输:**
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
完整消息 = 2 字节小端 JSON 长度 + JSON 的 UTF-8 字节
|
|||
|
|
每片最多 20 字节;接收端拼完整后再解析。JSON 最多 2048 字节。
|
|||
|
|
长度头只加一次,不含头本身,也不含字符串结尾的 NUL。
|
|||
|
|
|
|||
|
|
{"cmd":"get_info"} 的 JSON 长度为 18,长度头是 12 00(十六进制)。
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
小程序先订阅 TX,再向 RX 发命令。设备收到命令后回复,**不要一连接就主动推送 JSON**。发送 Indicate 分片时,收到确认再发下一片。
|
|||
|
|
|
|||
|
|
## 3. 蓝牙连接后:小程序读取设备信息
|
|||
|
|
|
|||
|
|
用途:让小程序知道设备编号和型号,用设备编号向平台查询绑定关系。
|
|||
|
|
|
|||
|
|
小程序 → 设备,输入:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"cmd":"get_info"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
设备 → 小程序,输出:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"device_id": "KP01-020000260914",
|
|||
|
|
"model": "ECHOEAR-S3",
|
|||
|
|
"protocol_version": 2
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 字段 | 中文说明与用途 |
|
|||
|
|
|---|---|
|
|||
|
|
| cmd | 命令,固定 get_info |
|
|||
|
|
| device_id | 烧录的 factory_id,不是手机扫描得到的蓝牙地址 |
|
|||
|
|
| model | 完整设备型号 |
|
|||
|
|
| protocol_version | 固定数字 2,小程序用来确认协议一致 |
|
|||
|
|
|
|||
|
|
## 4. 配网:小程序发送 Wi-Fi 和绑定信息
|
|||
|
|
|
|||
|
|
设备收到非空 bind_token 时进行绑定;收到空字符串时只重新配网并检查原绑定。设备不用处理用户登录。
|
|||
|
|
|
|||
|
|
小程序 → 设备,输入:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"cmd": "configure",
|
|||
|
|
"wifi_ssid": "HomeWiFi",
|
|||
|
|
"wifi_password": "Example123",
|
|||
|
|
"bind_token": "<小程序从平台取得的43字符令牌,原样传递>"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 字段 | 中文说明与用途 |
|
|||
|
|
|---|---|
|
|||
|
|
| cmd | 固定 configure |
|
|||
|
|
| wifi_ssid | Wi-Fi 名称,1–32 个 UTF-8 字节,当前设备使用 2.4 GHz Wi-Fi |
|
|||
|
|
| wifi_password | Wi-Fi 密码,当前支持 8–63 个可打印 ASCII 字符,不支持开放网络 |
|
|||
|
|
| bind_token | 非空表示绑定到选定老人;空字符串表示保留原绑定、只重新配网 |
|
|||
|
|
|
|||
|
|
设备立即回复:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"status":"received"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
只换 Wi-Fi 的输入示例:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"cmd":"configure","wifi_ssid":"NewHomeWiFi","wifi_password":"Example456","bind_token":""}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
设备收到后:回复 received → 提示用户轻点屏幕/确认键(60 秒内)→ 连接 Wi-Fi → 调用下一节的平台接口 → 成功后保存 Wi-Fi。
|
|||
|
|
|
|||
|
|
**received 只表示收到,不表示绑定成功。** bind_token 不传 null,也不要省略。Wi-Fi 密码不上传平台,设备不需要用户手机号或账号密码。
|
|||
|
|
|
|||
|
|
### 小程序查询处理结果
|
|||
|
|
|
|||
|
|
输入:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"cmd":"get_status"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
输出成功示例:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"status":"success"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
输出失败示例:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"status":"failed","message":"Wi-Fi 连接失败,请检查密码"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| status | 含义 |
|
|||
|
|
|---|---|
|
|||
|
|
| idle | 等待下发参数 |
|
|||
|
|
| received | 已接收,等待用户在设备上确认 |
|
|||
|
|
| wifi_connecting | 正在连接 Wi-Fi |
|
|||
|
|
| binding | 正在向平台绑定或检查已有绑定 |
|
|||
|
|
| success | 平台处理成功,Wi-Fi 已保存 |
|
|||
|
|
| failed | 失败,message 填具体原因 |
|
|||
|
|
|
|||
|
|
小程序约每 2.5 秒查询一次。设备只在收到 get_status 时回复,不主动推送。
|
|||
|
|
|
|||
|
|
## 5. 联网后:设备向平台绑定
|
|||
|
|
|
|||
|
|
下面都是 HTTP POST,正文为 JSON,必须携带末尾“平台请求公共格式”中的请求头。设备先完成校时。
|
|||
|
|
|
|||
|
|
### 5.1 bind_token 非空:提交绑定
|
|||
|
|
|
|||
|
|
地址:`POST https://82.157.201.114/api/v1/device/ble-bind`
|
|||
|
|
|
|||
|
|
用途:把这台设备绑定到小程序选定的老人。平台通过请求头 X-Device-ID 识别设备,通过 bind_token 找到老人。
|
|||
|
|
|
|||
|
|
输入:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"bind_token":"<蓝牙收到的原始令牌>"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
平台在本次请求中完成绑定、向火山注册(已注册则复用),拿齐配置后才返回成功。设备不需要另查“是否准备好”。
|
|||
|
|
|
|||
|
|
成功输出(业务字段,位于 HTTP 响应的 data 中):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"status":"success",
|
|||
|
|
"instance_id":"<火山实例>",
|
|||
|
|
"product_key":"<火山产品标识>",
|
|||
|
|
"device_name":"<本设备在火山的名称>",
|
|||
|
|
"device_secret":"<本设备专属火山密钥>",
|
|||
|
|
"RTCAppID":"<RTC应用ID>"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 字段 | 用途 |
|
|||
|
|
|---|---|
|
|||
|
|
| bind_token(输入) | 蓝牙收到的令牌原样转发,不再传账号、老人 ID 或 Wi-Fi |
|
|||
|
|
| status(输出) | success 表示绑定完成且下方 5 个字段均已取得 |
|
|||
|
|
| instance_id / product_key / device_name | 设备连接火山时使用的实例、产品和设备身份 |
|
|||
|
|
| device_secret | 火山设备密钥,不是烧录的 device_key;由平台注册取得并保存 |
|
|||
|
|
| RTCAppID | 初始化火山 RTC SDK 的应用 ID |
|
|||
|
|
|
|||
|
|
设备保存这 5 个字段,**不用调用火山 DynamicRegister,不再申请注册许可或上报 SDK_REGISTER 成功**。这些凭据只返回给设备,不通过蓝牙转发给小程序。
|
|||
|
|
|
|||
|
|
保存上述字段和 Wi-Fi 后,清理本次令牌,蓝牙 get_status 返回 success。绑定接口不再返回 voice_status、voice,也不会以 success 搭配空配置。
|
|||
|
|
|
|||
|
|
设备 HTTP 超时设为 **45 秒**;平台取参等待上限为 35 秒。网络超时或 5xx 时保留原 bind_token 和本次 X-Request-ID 重试(更新签名时间与 nonce),不要重新申请绑定。平台保留已完成的绑定和注册进度,不重复注册火山。
|
|||
|
|
|
|||
|
|
| HTTP / code | 含义与处理 |
|
|||
|
|
|---|---|
|
|||
|
|
| 504 / VOICE_REGISTRATION_TIMEOUT | 取参超时,原绑定已保存;使用原令牌重试 |
|
|||
|
|
| 502 / VOICE_REGISTRATION_FAILED | 注册失败或结果不确定,绑定已保存;持续出现请平台核对,不重新注册 |
|
|||
|
|
| 502 / VOICE_CONFIG_INVALID | 平台取得的配置不完整,请平台处理 |
|
|||
|
|
| 503 / VOICE_REGISTRATION_PAUSED | 语音服务暂停,绑定已保存;稍后原令牌重试 |
|
|||
|
|
| 409 / VOICE_MIGRATION_REQUIRED | 旧设备需补交原火山凭据,按迁移说明处理后原令牌重试 |
|
|||
|
|
| 409 / BINDING_CHANGED | 等待期间设备被解绑或归属变化,重新绑定 |
|
|||
|
|
|
|||
|
|
错误响应示例(外层):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"ok":false,"code":"VOICE_REGISTRATION_FAILED","message":"绑定已保存,火山注册结果未确认,请联系平台核对","request_id":"<本次请求UUID>"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.2 bind_token 为空:检查原绑定,不重新绑定
|
|||
|
|
|
|||
|
|
地址:`POST https://82.157.201.114/api/v1/device/ble-state`
|
|||
|
|
|
|||
|
|
输入必须是空 JSON 对象:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
输出(业务字段):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"bound":true}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
bound=true:保存新 Wi-Fi,蓝牙返回 success。bound=false:说明平台已经解绑,蓝牙返回 failed,message 填“设备没有绑定人员,请重新绑定”。空令牌不能让设备跳过平台检查。
|
|||
|
|
|
|||
|
|
### 5.3 可选:丢失本地配置后重新获取语音凭据
|
|||
|
|
|
|||
|
|
地址:`POST https://82.157.201.114/api/v1/device/voice-config`
|
|||
|
|
|
|||
|
|
输入:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
输出 data:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"voice_status":"ready",
|
|||
|
|
"voice":{
|
|||
|
|
"instance_id":"<火山实例>",
|
|||
|
|
"product_key":"<火山产品标识>",
|
|||
|
|
"device_name":"<本设备在火山的名称>",
|
|||
|
|
"device_secret":"<本设备专属火山密钥>",
|
|||
|
|
"RTCAppID":"<RTC应用ID>"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
这是已有的恢复接口,保留 voice_status / voice 格式;**正常绑定成功后不需要调用它**。绑定响应丢失时直接重试 ble-bind 即可。本机凭据丢失后可通过本接口取回原凭据,不重复注册火山。平台还在准备时会返回 `{"voice_status":"preparing","voice":null}`,每 5 秒再查;不需要另外领取初始化命令。
|
|||
|
|
|
|||
|
|
其他状态:paused=平台暂停语音准备;reconcile_required=注册结果不确定,需要平台核对;migration_required=旧设备凭据仅存于设备,需要按迁移说明补交一次。设备被解绑时返回 HTTP 409 / DEVICE_UNBOUND,不继续返回凭据。
|
|||
|
|
|
|||
|
|
## 6. 每次聊天之前:向平台申请聊天参数
|
|||
|
|
|
|||
|
|
地址:`POST https://82.157.201.114/api/v1/device/chat/start`
|
|||
|
|
|
|||
|
|
用途:平台查询“设备 → 已绑定老人 → 老人的智能体”,有绑定且语音已就绪才返回开聊参数。
|
|||
|
|
|
|||
|
|
**前置条件:设备已联网、绑定成功,已保存绑定响应的 5 个火山字段,并在本机初始化 SDK。** 不再要求向平台注册开机、重新领配置或上报 SDK 初始化确认。每次直接调用下述 chat/start。
|
|||
|
|
|
|||
|
|
输入:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"device_id": "KP01-020000260914",
|
|||
|
|
"session_id": "5b5b9120-58c3-4433-96e1-67923598cafa"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 输入字段 | 中文说明与用途 |
|
|||
|
|
|---|---|
|
|||
|
|
| device_id | 设备编号,与 X-Device-ID 一致 |
|
|||
|
|
| session_id | 本次聊天编号,设备每次新聊天生成一个 UUID v4;这次聊天结束前不变 |
|
|||
|
|
|
|||
|
|
输出(业务字段,示意值):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"session_id": "5b5b9120-58c3-4433-96e1-67923598cafa",
|
|||
|
|
"bot_id": "<该老人对应的火山智能体ID>",
|
|||
|
|
"params": {
|
|||
|
|
"PlatformTaskID": "5b5b9120-58c3-4433-96e1-67923598cafa",
|
|||
|
|
"AgentConfig": {
|
|||
|
|
"WelcomeMessage": "你好,今天想聊些什么?",
|
|||
|
|
"EnableConversationStateCallback": false,
|
|||
|
|
"ServerMessageURLForRTS": "https://82.157.201.114/callbacks/v1/volcengine/chat?session_id=5b5b9120-58c3-4433-96e1-67923598cafa",
|
|||
|
|
"ServerMessageSignatureForRTS": "<平台返回值,原样使用>"
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"lease_expires_at_ms": 1789372845000,
|
|||
|
|
"expires_at_ms": 1789376400000
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 输出字段 | 中文说明与设备用途 |
|
|||
|
|
|---|---|
|
|||
|
|
| session_id | 对应本次聊天,结束时传同一个值 |
|
|||
|
|
| bot_id | 本次使用的老人智能体,传给火山 SDK |
|
|||
|
|
| params.PlatformTaskID | 本次火山任务标识,传给 task_id;回调归属仍以回调 URL 中的 session_id 为准 |
|
|||
|
|
| params.AgentConfig | 本次欢迎语和回调设置,整体传给 SDK;底层 GetRTCConfig 的字段叫 agent_config |
|
|||
|
|
| lease_expires_at_ms | 当前允许聊天的截止时间,UTC 毫秒;需要续期 |
|
|||
|
|
| expires_at_ms | 整通聊天的最终截止时间,UTC 毫秒 |
|
|||
|
|
|
|||
|
|
拿到成功响应后,把 bot_id 和 params 交给本项目火山 SDK,结合第 5 节平台下发的 5 个语音字段获取房间并开始收发音频。凭据不是每次 chat/start 返回。**S2SConfig、豆包模型、MemoryConfig 由平台配置在老人的智能体上,设备不用再配置。**
|
|||
|
|
|
|||
|
|
聊天过程中每 10 秒用相同输入再请求一次 chat/start,更新有效期;不重新加入房间。续期失败不能自行延长,到期或收到解绑错误就停止聊天并上报结束。
|
|||
|
|
|
|||
|
|
没有绑定时,HTTP 409 输出示例:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"ok": false,
|
|||
|
|
"code": "DEVICE_UNBOUND",
|
|||
|
|
"message": "绑定错误:设备没有绑定人员"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
此时设备提示重新绑定,不使用以前保存的 bot_id 开聊。DEVICE_NOT_READY 表示平台尚未确认火山设备注册完成,VOICE_AGENT_NOT_READY 表示平台尚未准备好老人的智能体。
|
|||
|
|
|
|||
|
|
### 聊天记录怎么回平台
|
|||
|
|
|
|||
|
|
**火山直接回调平台,设备不上传聊天文字。** 设备只需把上例的回调 URL 和签名原样传给火山 SDK,不计算这个签名,也不把它当成 Function calling 地址。
|
|||
|
|
|
|||
|
|
平台负责接收、保存和关联老人;平台端需已开启该老人的聊天记录存储授权。回调 URL 中的 `session_id` 对应本通聊天,不替换为老人 ID,不复用上一通的 URL/签名。火山回调消息本身不保证带本平台的会话编号,不能仅靠 PlatformTaskID 或“老人最新一通聊天”关联记录。
|
|||
|
|
|
|||
|
|
## 7. 聊天结束:设备通知平台
|
|||
|
|
|
|||
|
|
地址:`POST https://82.157.201.114/api/v1/device/chat/end`
|
|||
|
|
|
|||
|
|
用途:通知平台本次聊天已经结束。设备先停麦克风、播放和火山 SDK,再发送。
|
|||
|
|
|
|||
|
|
输入:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"device_id": "KP01-020000260914",
|
|||
|
|
"session_id": "5b5b9120-58c3-4433-96e1-67923598cafa",
|
|||
|
|
"reason": "hangup",
|
|||
|
|
"sdk_stopped": true
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 输入字段 | 中文说明与用途 |
|
|||
|
|
|---|---|
|
|||
|
|
| device_id | 设备编号 |
|
|||
|
|
| session_id | 开始聊天时使用的同一个编号 |
|
|||
|
|
| reason | hangup=正常挂断;start_failed=启动失败;disconnected=连接中断;reboot=重启补报;timeout=超时 |
|
|||
|
|
| sdk_stopped | 只有确认 SDK 已停止才填 true,没确认则填 false |
|
|||
|
|
|
|||
|
|
输出(业务字段):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"session_id":"5b5b9120-58c3-4433-96e1-67923598cafa","ended":true}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
ended=true 表示平台已接收结束通知。断网时保存待上报任务,联网后重试;重启补报使用 reason=reboot、sdk_stopped=false。再次聊天生成新的 session_id。
|
|||
|
|
|
|||
|
|
## 8. MQTT 连接与设备心跳
|
|||
|
|
|
|||
|
|
### 设备取得连接参数
|
|||
|
|
|
|||
|
|
`POST https://82.157.201.114/api/v1/device/mqtt-config`
|
|||
|
|
|
|||
|
|
输入:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
输出示例:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"broker_url": "mqtt://82.157.201.114:1883",
|
|||
|
|
"client_id": "KP01-020000260914",
|
|||
|
|
"username": "KP01-020000260914",
|
|||
|
|
"password": "<平台返回值,原样使用>",
|
|||
|
|
"heartbeat_interval_seconds": 30,
|
|||
|
|
"keepalive_seconds": 60,
|
|||
|
|
"status_topic": "vl/devices/KP01-020000260914/status",
|
|||
|
|
"ota_topic": "vl/devices/KP01-020000260914/ota",
|
|||
|
|
"qos": 1
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 字段 | 中文说明与用途 |
|
|||
|
|
|---|---|
|
|||
|
|
| broker_url | MQTT 服务器,TCP 1883,明文连接 |
|
|||
|
|
| client_id / username / password | 连接时原样使用,不是老人的账号密码 |
|
|||
|
|
| heartbeat_interval_seconds | 心跳间隔,30秒 |
|
|||
|
|
| keepalive_seconds | MQTT Keep Alive,60秒 |
|
|||
|
|
| status_topic | 设备发布在线心跳的主题 |
|
|||
|
|
| ota_topic | 设备订阅升级指令的主题 |
|
|||
|
|
| qos | 消息等级1,可能重复投递,升级需要按 task_id 去重 |
|
|||
|
|
|
|||
|
|
使用 MQTT 3.1.1,Clean Session=true。每次重连后重新订阅 ota_topic,订阅成功后立即上报心跳。消息是完整 UTF-8 JSON,不加蓝牙长度头,Retain=false。
|
|||
|
|
|
|||
|
|
### 设备每30秒发布心跳
|
|||
|
|
|
|||
|
|
发送到返回的 status_topic:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"online": true,
|
|||
|
|
"firmware_version": "1.0.0",
|
|||
|
|
"version_code": 0,
|
|||
|
|
"ota_supported": true
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 输入字段 | 中文说明与用途 |
|
|||
|
|
|---|---|
|
|||
|
|
| online | true=在线;主动离线时发送false |
|
|||
|
|
| firmware_version | 当前实际运行固件的显示版本名称 |
|
|||
|
|
| version_code | 当前实际运行固件的数字顺序号;尚未关联平台固件的出厂版本填0,完成升级后使用指令里的数字 |
|
|||
|
|
| ota_supported | 最终硬件已实现OTA时填true,否则false,小程序不会允许更新 |
|
|||
|
|
|
|||
|
|
设置遗嘱主题为自己的 status_topic,遗嘱内容:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"online": false}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
平台以 MQTT 连接和最近心跳判断在线:连接断开即离线,超过90秒未收到心跳也视为离线。仅建立连接、未发心跳不算在线;时间以服务器接收时间为准。
|
|||
|
|
|
|||
|
|
设备不用自行计算 MQTT 密码。若需要排查,当前平台生成方法是 `Base64URL无填充(HMAC-SHA256(device_key原始32字节, UTF8("MQTT1\n" + factory_id)))`。与已有设备 HTTP 签名不是同一个计算内容。
|
|||
|
|
|
|||
|
|
## 9. 设备通过 MQTT 接收 OTA 升级指令
|
|||
|
|
|
|||
|
|
接收主题:mqtt-config 返回的 ota_topic。
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"task_id": "f58d3018-38a8-4fc4-b490-4d3154fa8464",
|
|||
|
|
"model": "ECHOEAR-S3",
|
|||
|
|
"version": "1.1.0",
|
|||
|
|
"version_code": 12,
|
|||
|
|
"download_url": "https://82.157.201.114/private-exports/<平台返回的临时下载路径>",
|
|||
|
|
"sha256": "abababababababababababababababababababababababababababababababab",
|
|||
|
|
"size": 3145728,
|
|||
|
|
"expires_at_ms": 1789398030000
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 输入字段 | 中文说明与用途 |
|
|||
|
|
|---|---|
|
|||
|
|
| task_id | 本次升级任务,持久化保存,用于去重和重启后的结果上报 |
|
|||
|
|
| model | 必须与设备型号一致 |
|
|||
|
|
| version / version_code | 目标版本名称/平台分配的数字顺序号;同型号顺序号越大越新,必须大于当前值,不比较版本名称字符串 |
|
|||
|
|
| download_url | HTTPS下载固件,不通过MQTT传文件 |
|
|||
|
|
| sha256 / size | 下载文件原始字节的 SHA256(64 位小写十六进制)/总字节数;两者都要核对,C 函数见附录 C |
|
|||
|
|
| expires_at_ms | 指令最晚开始接收时间,Unix毫秒,发出后30秒;过期指令不能开始升级 |
|
|||
|
|
|
|||
|
|
设备步骤:检查型号、顺序号、有效期和当前是否空闲 → 持久化task_id和目标版本 → HTTP上报accepted → 上报成功后下载并校验 → 安装并重启 → 确认运行新版本后上报success。下载/校验/安装失败上报failed;正在通话时拒绝并上报failed及原因。同一task_id重复到达只回复已有进度,不再执行安装。
|
|||
|
|
|
|||
|
|
## 10. 设备报告升级结果
|
|||
|
|
|
|||
|
|
`POST https://82.157.201.114/api/v1/device/ota/report`
|
|||
|
|
|
|||
|
|
设备已接收:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"task_id":"f58d3018-38a8-4fc4-b490-4d3154fa8464","status":"accepted"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
重启并确认运行目标固件后:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"task_id":"f58d3018-38a8-4fc4-b490-4d3154fa8464","status":"success","firmware_version":"1.1.0","version_code":12}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
失败:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"task_id":"f58d3018-38a8-4fc4-b490-4d3154fa8464","status":"failed","message":"固件下载失败,仍运行原版本"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
成功输出 data:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"status":"ok"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 输入字段 | 中文说明与用途 |
|
|||
|
|
|---|---|
|
|||
|
|
| task_id | 必须是平台发给本设备的升级任务ID |
|
|||
|
|
| status | accepted=接收并准备升级;success=实际运行新版本;failed=升级失败 |
|
|||
|
|
| firmware_version / version_code | success时必填,必须同时与目标名称和数字顺序号一致 |
|
|||
|
|
| message | 失败原因,最多500字节,可省略 |
|
|||
|
|
|
|||
|
|
上报允许重试;完成任务后继续每30秒心跳,填写实际运行的版本和顺序号。不能只因为下载完就上报success。网络不通时保留结果,重连后补报。
|
|||
|
|
|
|||
|
|
平台任务状态:pending=已发送、accepted=设备升级中、success=已完成、failed=失败、timeout=结果超时。30秒未确认或10分钟未完成转为timeout,超时不代表升级成功;设备可以补报success/failed。过期任务不能再接收为accepted。
|
|||
|
|
|
|||
|
|
防火墙:设备 MQTT 需要公网 TCP 1883,HTTP 接口与固件下载仍走 HTTPS 443。当前 MQTT 链路不使用 TLS,不需要给小程序直接连接 MQTT。
|
|||
|
|
|
|||
|
|
## 附录 A. 平台请求公共格式(统一封装一次)
|
|||
|
|
|
|||
|
|
蓝牙不加密;设备发起的平台 HTTPS POST 都带以下请求头,MQTT 使用第 8 节 CONNECT 配置。联网后用系统 RTC/SNTP 取得正确 UTC 时间,再发送带时间戳的请求;这不是向平台注册开机,不需要增加平台登记接口。
|
|||
|
|
|
|||
|
|
| 请求头 | 填什么 |
|
|||
|
|
|---|---|
|
|||
|
|
| Content-Type | application/json |
|
|||
|
|
| X-Device-ID | 烧录的 factory_id |
|
|||
|
|
| X-Key-Version | 1 |
|
|||
|
|
| X-Timestamp-Ms | 已校时的当前 UTC 毫秒时间,十进制字符串 |
|
|||
|
|
| X-Nonce | 每次请求生成 16 随机字节,base64url 无填充,22 字符 |
|
|||
|
|
| X-Request-ID | UUID v4;同一次绑定重试保持不变,正文也保持不变 |
|
|||
|
|
| Authorization | Device-HMAC-SHA256 加空格,再加下方计算出的签名 |
|
|||
|
|
|
|||
|
|
只有 Authorization 需要计算。使用烧录的 device_key,而不是火山密钥:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
body_sha = 小写hex(SHA256(实际发送的JSON字节))
|
|||
|
|
原文 = 用真实换行符LF连接以下9项,最后不加换行:
|
|||
|
|
DEVREQ1
|
|||
|
|
POST
|
|||
|
|
接口路径(例如 /api/v1/device/ble-bind,不含域名)
|
|||
|
|
X-Device-ID
|
|||
|
|
X-Key-Version
|
|||
|
|
X-Timestamp-Ms
|
|||
|
|
X-Nonce
|
|||
|
|
X-Request-ID
|
|||
|
|
body_sha
|
|||
|
|
|
|||
|
|
签名 = 小写hex(HMAC-SHA256(device_key的32字节, 原文的UTF-8字节))
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
每次重试更新时间、随机数和签名。通用计算代码见 [embedded-signing-reference.cjs](embedded-signing-reference.cjs),离线验算见 [test-vectors.json](test-vectors.json)。UUID 和随机数示例不能在真实请求里固定复用。
|
|||
|
|
|
|||
|
|
设备接口 `/api/v1/device/...` 成功响应的完整外层如下。正文为避免重复,大部分“输出”示例只展示 data 的内容:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"ok": true,
|
|||
|
|
"code": "OK",
|
|||
|
|
"request_id": "<本次请求UUID>",
|
|||
|
|
"server_time_ms": 1789372800000,
|
|||
|
|
"data": {"status":"success"}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
只在 HTTP 成功且 ok=true 时读取 data;失败看 code/message,不把收到响应当成业务成功。聊天有效期的示例时间只是示意,必须使用真实响应。
|
|||
|
|
|
|||
|
|
聊天续期剩余毫秒数 = lease_expires_at_ms − server_time_ms − 本次请求往返耗时;用单调时钟倒计时,不按“现在加45秒”自行续期。
|
|||
|
|
|
|||
|
|
设备 HTTP 业务失败返回 `ok=false`、`code`、`message`、`request_id` 等字段;第 6 节未绑定示例省略了 request_id。
|
|||
|
|
|
|||
|
|
MQTT 消息和蓝牙 JSON 不套 HTTP 外层。
|
|||
|
|
|
|||
|
|
## 附录 B. 设备本地启动(不请求平台登记)
|
|||
|
|
|
|||
|
|
设备每次开机只需在本地完成以下工作:
|
|||
|
|
|
|||
|
|
1. 读取已保存的 Wi-Fi 和 5 个火山字段;没有 Wi-Fi 就等待蓝牙配网。
|
|||
|
|
2. 连网,用 RTC/SNTP 校时;使用已保存的火山凭据初始化 SDK。
|
|||
|
|
3. 获取 MQTT 配置、连接并每 30 秒发心跳。
|
|||
|
|
4. 用户开始聊天时,生成新的 session_id,请求 chat/start,使用本次 bot_id 和 params 开聊。
|
|||
|
|
5. 停止聊天后上报 chat/end;断网/重启时保存待补报的 session_id,联网后先补报,再开始新聊天。
|
|||
|
|
|
|||
|
|
**不再调用平台开机登记、语音配置命令领取、SDK 初始化确认接口,也不需要发送 boot_id。** 本地 SDK 初始化失败就不要开聊;已经申请过会话的,按 start_failed 上报结束。
|
|||
|
|
|
|||
|
|
首次绑定通过 ble-bind 直接拿齐火山凭据。只有本机凭据丢失时,才使用第 5.3 节 voice-config 恢复;无需每次开机重复获取。硬件在火山侧只由平台注册一次,重新绑定老人也不重复注册。
|
|||
|
|
|
|||
|
|
设备 HTTPS 请求仍需有效的 CA 信任配置与正确的 UTC 时间。旧启动接口仅供既有固件兼容,新接入不需要实现。
|
|||
|
|
|
|||
|
|
## 附录 C. 需要计算的字段:C 函数
|
|||
|
|
|
|||
|
|
这部分只是协议计算辅助函数,**不是完整固件**,不绑定 EchoEar 或某个芯片。使用 C99 和 Mbed TLS 的 MD/SHA256/Base64 模块;目标工程开启 `MBEDTLS_MD_C`、`MBEDTLS_SHA256_C`、`MBEDTLS_BASE64_C` 并链接对应库。不同芯片的随机数、Flash、网络、蓝牙收发和 JSON 解析由嵌入式工程接入。
|
|||
|
|
|
|||
|
|
可直接取用:[vl_protocol.h](device-protocol-c/vl_protocol.h)、[vl_protocol.c](device-protocol-c/vl_protocol.c)。所有函数 **0 表示成功,非 0 表示失败**;失败时不能继续发送或安装。字符串输出容量包含结尾 `\0`,发送长度不包含 `\0`,输入/输出缓冲区不要重叠。
|
|||
|
|
|
|||
|
|
| 要处理的字段/数据 | C 函数 | 输入与输出用途 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| device_key_hex | vl_key_from_hex | 64 字符十六进制 → 32 字节设备密钥;不能直接把 64 字符文本当 HMAC 密钥 |
|
|||
|
|
| X-Nonce | vl_nonce | 通过回调取 16 个硬件/系统随机字节 → 22 字符 Base64URL;out 至少 23 字节 |
|
|||
|
|
| session_id、X-Request-ID | vl_uuid4 | 通过随机源生成 UUID v4;out 至少 37 字节;各 ID 的生成/重用时机按正文 |
|
|||
|
|
| Authorization 后面的签名 | vl_http_sign | 设备密钥、路径、请求头值和实际 JSON 字节 → 64 字符小写签名;out 至少 65 字节 |
|
|||
|
|
| BLE 长度头 | vl_ble_pack | UTF-8 JSON 字节 → 2 字节小端长度头 + JSON;最长输出 2050 字节 |
|
|||
|
|
| 普通内容 SHA256 | vl_sha256_hex | 原始字节 → 64 字符小写 SHA256 |
|
|||
|
|
| OTA 下载文件 | vl_ota_verify | 分块读取已下载文件,同时核对实际字节数和平台 sha256;每次只读 1024 字节 |
|
|||
|
|
|
|||
|
|
**以下字段不用设备计算:** bind_token、bot_id、火山 5 个语音字段、MQTT password、ServerMessageURLForRTS、ServerMessageSignatureForRTS、OTA task_id/download_url/version_code。全部取平台或小程序返回值,不用这些函数自行生成替代值。
|
|||
|
|
|
|||
|
|
### C.1 HTTP 签名调用示例
|
|||
|
|
|
|||
|
|
以下是调用片段,`device_key_hex` 来自烧录配置;nonce、request_id、timestamp_ms 按附录 A 取得。签名和发送必须使用同一份 body,不能签名后重新格式化 JSON:
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
uint8_t device_key[32];
|
|||
|
|
char signature[65], authorization[96];
|
|||
|
|
const char *body = "{}";
|
|||
|
|
if (vl_key_from_hex(device_key_hex, device_key) != 0) return -1;
|
|||
|
|
if (vl_http_sign(device_key, "POST", "/api/v1/device/ble-state",
|
|||
|
|
"KP01-020000260914", "1", timestamp_ms, nonce,
|
|||
|
|
request_id, body, strlen(body), signature) != 0) return -1;
|
|||
|
|
int n = snprintf(authorization, sizeof(authorization),
|
|||
|
|
"Device-HMAC-SHA256 %s", signature);
|
|||
|
|
if (n < 0 || (size_t)n >= sizeof(authorization)) return -1;
|
|||
|
|
/* Authorization 请求头填 authorization;HTTP body 发送上面 body 的原始字节。 */
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
使用片段时包含 `vl_protocol.h`、`stdio.h`、`string.h`。每次重试重新生成 nonce 和时间;同一逻辑操作的 request_id 与 body 按正文保持不变。
|
|||
|
|
|
|||
|
|
### C.2 蓝牙打包与 OTA 校验
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
const char *json_text = "{\"cmd\":\"get_info\"}";
|
|||
|
|
uint8_t frame[2050];
|
|||
|
|
size_t frame_length = 0;
|
|||
|
|
if (vl_ble_pack(json_text, strlen(json_text), frame,
|
|||
|
|
sizeof(frame), &frame_length) != 0) return -1;
|
|||
|
|
/* frame_length=20;前两个字节为 0x12,0x00。
|
|||
|
|
* 把完整 frame 每次最多切 20 字节发送,只有整条消息开头加长度头。
|
|||
|
|
* 此 get_info 是小程序->设备示例;设备回复 JSON 使用同一个打包函数。 */
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
接收方先累积至少 2 字节,解析 `length = buffer[0] | (buffer[1] << 8)`,长度必须为 1–2048;继续累积满 length 个 JSON 字节再解析。收到下一包不是新消息开始,不要给每片都读一个长度头。
|
|||
|
|
|
|||
|
|
OTA 使用 `vl_ota_verify(read_firmware, reader_context, command_size, command_sha256)`。`read_firmware` 由嵌入式接入已下载文件/Flash 分块读取:返回 0 且 `*n>0` 表示读到数据,返回 0 且 `*n=0` 表示结束,非 0 表示读取失败;每次不超过 capacity。校验返回 0 后才安装,**仍需确认新版本实际运行再上报 success**。
|
|||
|
|
|
|||
|
|
随机源回调须一次填满指定字节数并返回 0,失败返回非 0;用芯片硬件随机源或系统随机 API,不能用 rand()、时间戳或固定数组。离线测试用的固定随机数不能搬进固件。
|
|||
|
|
|
|||
|
|
下面交接导出版本附有上述 `.h`/`.c` 的完整内容,便于只转发一个 Markdown 文件;旁边的独立文件内容完全一致。
|
|||
|
|
|
|||
|
|
<!-- VL_PROTOCOL_C_SOURCE -->
|