# 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":"" } ``` | 字段 | 用途 | |---|---| | 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":"" } } ``` 这是已有的恢复接口,保留 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 文件;旁边的独立文件内容完全一致。