Files
story/docs/embedded-device-business-guide-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

649 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 -->