1460 lines
47 KiB
Markdown
1460 lines
47 KiB
Markdown
# 嵌入式设备与平台交互接口文档
|
||
|
||
> 文档范围:嵌入式语音终端与平台、RTC 和对象存储的接口及交互流程
|
||
> 设备示例:`10000000-0000-0000-0000-000000000512`
|
||
> 设备接口版本:`/device/v2`
|
||
> OpenAPI 契约版本:`0.70.0`
|
||
> 本地平台地址:`http://localhost:58080`
|
||
> 公网测试地址:`https://121.41.46.238`
|
||
> 编制日期:2026-08-29
|
||
|
||
## 1. 适用范围和重要前提
|
||
|
||
本文档面向嵌入式固件、BSP、音频和设备联调开发人员,说明设备从启动登记到完成一次语音通话并上传录音的完整过程,包括:
|
||
|
||
- 主通话链路使用的 `/device/v2` 平台接口;
|
||
- 每个接口的请求参数、响应参数和示例;
|
||
- 设备 HMAC 签名、Nonce 和幂等键的生成规则;
|
||
- 火山 RTC 和对象存储的非平台 API 交互;
|
||
- 正常流程、轮询、重试和异常清理顺序。
|
||
|
||
仓库本地种子数据中的示例设备 `10000000-0000-0000-0000-000000000512` 当前是注册但未绑定设备:
|
||
|
||
```text
|
||
status = registered
|
||
credential_ref = local-dev://unbound-terminal-2
|
||
binding.bound = false
|
||
```
|
||
|
||
因此,在未绑定状态下:
|
||
|
||
- `GET /device/v2/bootstrap` 和 `GET /device/v2/config` 可以通过;
|
||
- `POST /device/v2/conversations` 将返回 `409 DEVICE_NOT_BOUND`;
|
||
- 后续成功通话示例是假设设备 `...0512` 已经绑定到有效家庭和服务对象,并取得 `voice_chat` 授权后的结果。
|
||
|
||
设备 ID 和设备密钥必须成对。使用设备 `...0511` 的密钥为 `...0512` 签名会返回 `401 DEVICE_AUTHENTICATION_FAILED`。
|
||
|
||
本文使用“必须”“应”“可以”区分协议要求、推荐实现和可选能力。Windows 模拟器仅作为已验证参考实现,不构成嵌入式设备的运行时约束。
|
||
|
||
## 2. 主通话链路接口
|
||
|
||
| 阶段 | 顺序 | 方法与路径 | 调用方式 |
|
||
|---|---:|---|---|
|
||
| 环境检测 | 1 | `GET /device/v2/bootstrap` | 调用一次 |
|
||
| 环境检测 | 2 | `GET /device/v2/config` | 调用一次 |
|
||
| 创建通话 | 3 | `POST /device/v2/conversations` | 调用一次,幂等 |
|
||
| 确认入房 | 4 | `POST /device/v2/conversations/{id}/activate` | RTC 入房成功并满足产品前置条件后调用,幂等 |
|
||
| 等待智能体 | 5 | `GET /device/v2/conversations/{id}` | 首次约 500 ms,之后建议每 1 秒轮询,不超过房间预留期 |
|
||
| 通话中 | 6 | `POST /device/v2/conversations/{id}/heartbeat` | 激活后立即一次,之后通常每 15 秒一次 |
|
||
| 通话中 | 7 | `POST /device/v2/conversations/{id}/turns:batch` | 每次收到最终字幕时调用;失败后在心跳或结束前重试 |
|
||
| 结束通话 | 8 | `POST /device/v2/conversations/{id}/stop` | 本地 RTC 离房后调用,幂等 |
|
||
| 等待结束 | 9 | `GET /device/v2/conversations/{id}` | 有录音时建议每 1 秒轮询,并设置本地上限 |
|
||
| 录音上传 | 10 | `POST /device/v2/conversations/{id}/upload-batches` | 有授权录音时调用,幂等 |
|
||
| 录音上传 | 外部 | `PUT {upload_url}` | 录音字节直接上传对象存储 |
|
||
| 录音上传 | 11 | `POST /device/v2/upload-batches/{batch_id}/complete` | 所有对象上传后调用 |
|
||
| 录音校验 | 12 | `GET /device/v2/upload-batches/{batch_id}` | 建议每 1~2 秒轮询,直到终态、批次到期或本地上限 |
|
||
| 本地清理 | 13 | `POST /device/v2/media-segments/{segment_id}/local-delete-ack` | 到 `local_delete_after` 后删除本地密文并回报 |
|
||
|
||
以下能力不在本文主通话流程中展开,嵌入式产品应按产品需求实现:
|
||
|
||
- `GET /device/v2/commands`
|
||
- `POST /device/v2/commands/{id}/ack`
|
||
- `GET /device/v2/firmware-releases/{id}/download`
|
||
- `POST /device/v2/telemetry:batch`
|
||
|
||
## 3. 总体时序
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant D as 嵌入式语音终端
|
||
participant A as 平台API
|
||
participant R as 火山RTC
|
||
participant W as Worker
|
||
participant B as 豆包智能体
|
||
participant O as 对象存储
|
||
|
||
D->>A: GET /device/v2/bootstrap
|
||
A-->>D: 设备、绑定、固件和配置状态
|
||
D->>A: GET /device/v2/config
|
||
A-->>D: 期望配置和配置版本
|
||
D->>R: 可选的TLS 443连通性预检
|
||
|
||
D->>A: POST /device/v2/conversations
|
||
A-->>D: conversation_id、RTC房间和短期Token
|
||
D->>R: 加入房间、发布麦克风、启动字幕
|
||
D->>A: POST /device/v2/conversations/{id}/activate
|
||
A->>W: agent_start_requested
|
||
W->>B: StartVoiceChat
|
||
B->>R: 智能体加入RTC房间
|
||
|
||
loop 首次约500ms,之后建议每1秒,不超过房间预留期
|
||
D->>A: GET /device/v2/conversations/{id}
|
||
A-->>D: wait / continue / leave_room
|
||
end
|
||
|
||
loop 通话期间
|
||
D->>A: POST /device/v2/conversations/{id}/heartbeat
|
||
A-->>D: actions、配置版本、命令
|
||
D->>A: POST /device/v2/conversations/{id}/turns:batch
|
||
A-->>D: 最终字幕接收结果
|
||
D->>R: 上行麦克风音频
|
||
R-->>D: 下行豆包音频
|
||
end
|
||
|
||
D->>R: 停字幕、离房并销毁RTC引擎
|
||
D->>A: POST /device/v2/conversations/{id}/stop
|
||
A->>W: agent_stop_requested
|
||
W->>B: StopVoiceChat
|
||
|
||
opt 本次存在已授权录音
|
||
loop 等待通话终态
|
||
D->>A: GET /device/v2/conversations/{id}
|
||
A-->>D: STOPPING / COMPLETED / FAILED
|
||
end
|
||
D->>A: POST /device/v2/conversations/{id}/upload-batches
|
||
A-->>D: 批次、分片和短期PUT地址
|
||
D->>O: PUT near/far WAV分片
|
||
D->>A: POST /device/v2/upload-batches/{batch_id}/complete
|
||
loop 等待校验
|
||
D->>A: GET /device/v2/upload-batches/{batch_id}
|
||
A-->>D: VERIFYING / COMPLETED / FAILED
|
||
end
|
||
D->>D: 到local_delete_after后删除本地密文
|
||
D->>A: POST /device/v2/media-segments/{id}/local-delete-ack
|
||
end
|
||
```
|
||
|
||
## 4. 通用协议
|
||
|
||
### 4.1 成功和错误包装
|
||
|
||
一般成功响应:
|
||
|
||
```json
|
||
{
|
||
"request_id": "01JTESTREQUEST",
|
||
"data": {}
|
||
}
|
||
```
|
||
|
||
统一错误响应:
|
||
|
||
```json
|
||
{
|
||
"request_id": "01JTESTERROR",
|
||
"error": {
|
||
"code": "VALIDATION_FAILED",
|
||
"message": "请求字段不符合契约",
|
||
"retryable": false,
|
||
"field_errors": [
|
||
{
|
||
"field": "body.device_time",
|
||
"message": "必须是RFC3339时间"
|
||
}
|
||
],
|
||
"details": {}
|
||
}
|
||
}
|
||
```
|
||
|
||
设备应该使用 `error.code` 和 `retryable` 决定动作,不应匹配中文 `message`。
|
||
|
||
### 4.2 全部设备接口的 HMAC 请求头
|
||
|
||
每一个 `/device/v2` 请求都必须携带:
|
||
|
||
| 请求头 | 示例 | 生成方式 |
|
||
|---|---|---|
|
||
| `X-Device-Id` | `10000000-0000-0000-0000-000000000512` | 平台为设备分配的公开 UUID |
|
||
| `X-Device-Timestamp` | `2026-08-29T08:30:00.123Z` | 每次请求生成当前 UTC RFC3339 时间 |
|
||
| `X-Device-Nonce` | `5d4c79fa3f014e9cacb68eeb481be2c8` | 每次 HTTP 尝试生成新的 16~128 字符随机数 |
|
||
| `X-Device-Content-SHA256` | 64 位小写十六进制 | 对实际发送的原始正文 UTF-8 字节计算 SHA-256 |
|
||
| `X-Device-Signature` | Base64URL,无 `=` | 使用设备密钥计算 HMAC-SHA256 |
|
||
|
||
设备密钥不发送给平台。量产设备应把密钥保存在安全元件、可信执行环境或受保护的加密存储中,签名运算应尽量在安全边界内完成。
|
||
|
||
#### 4.2.1 `X-Device-Id`
|
||
|
||
该值来自平台设备注册/生产烧录流程,是设备的公开 UUID,不需要计算。本文示例固定为:
|
||
|
||
```text
|
||
10000000-0000-0000-0000-000000000512
|
||
```
|
||
|
||
设备必须把 Device ID 与对应的 `device_secret` 成对保存。Device ID 可以出现在普通日志中,`device_secret` 不可以。
|
||
|
||
#### 4.2.2 `X-Device-Timestamp`
|
||
|
||
该值由设备在每次 HTTP 尝试前读取当前 UTC 时间生成,格式必须为 RFC3339。推荐统一使用带毫秒的 UTC `Z` 格式:
|
||
|
||
```text
|
||
2026-08-29T08:31:00.050Z
|
||
```
|
||
|
||
生成要求:
|
||
|
||
1. 设备启动后通过可信 RTC/NTP、蜂窝网络时间或平台 `bootstrap.data.server_time` 校时。
|
||
2. 默认服务端允许时间偏差为前后 5 分钟,量产固件应把本地允许偏差控制得更小。
|
||
3. 写入请求头的字符串必须原样写入签名原文,不能在签名前后改变小数位、时区或大小写。
|
||
4. 网络重试时必须生成新的 Timestamp、Nonce 和 Signature。
|
||
|
||
伪代码:
|
||
|
||
```c
|
||
// utc_now_ms() 必须返回已经校准的 UTC 时间。
|
||
format_rfc3339_utc(utc_now_ms(), timestamp, sizeof(timestamp));
|
||
// 期望输出示例:2026-08-29T08:31:00.050Z
|
||
```
|
||
|
||
#### 4.2.3 `X-Device-Nonce`
|
||
|
||
Nonce 用于阻止签名请求被重放。服务端会按设备 ID 保存近期已经使用的 Nonce,同一个 Nonce 再次出现会被拒绝。
|
||
|
||
推荐生成方式:使用密码学安全随机数生成器产生 16 个随机字节,再编码成 32 个小写十六进制字符:
|
||
|
||
```c
|
||
uint8_t nonce_raw[16];
|
||
char nonce_hex[33];
|
||
|
||
// rng 必须是芯片 TRNG 或已经正确播种的 CSPRNG,不能使用 rand()。
|
||
secure_random(rng, nonce_raw, sizeof(nonce_raw));
|
||
hex_lower(nonce_raw, sizeof(nonce_raw), nonce_hex, sizeof(nonce_hex));
|
||
```
|
||
|
||
示例:
|
||
|
||
```text
|
||
5d4c79fa3f014e9cacb68eeb481be2c8
|
||
```
|
||
|
||
要求:
|
||
|
||
- 长度 16~128 个字符,推荐 32 个十六进制字符;
|
||
- 每一次 HTTP 尝试都重新生成,包括相同幂等请求的重试;
|
||
- 不能使用开机计数器、时间戳或 `rand()` 作为唯一随机源;
|
||
- Nonce 不等于 `Idempotency-Key`,两者生命周期不同。
|
||
|
||
#### 4.2.4 `X-Device-Content-SHA256`
|
||
|
||
该值是“真正发送到网络上的 HTTP 正文字节”的 SHA-256,编码为 64 个小写十六进制字符。
|
||
|
||
正确处理顺序:
|
||
|
||
1. 把请求对象序列化一次,得到最终 UTF-8 字节数组 `body_bytes`。
|
||
2. 对 `body_bytes[0..body_length-1]` 计算 SHA-256。
|
||
3. 把 32 字节摘要编码为 64 字符小写十六进制。
|
||
4. 使用同一个 `body_bytes` 和相同长度发送 HTTP 请求,不能计算完摘要后再次序列化 JSON。
|
||
|
||
伪代码:
|
||
|
||
```c
|
||
uint8_t digest_raw[32];
|
||
char digest_hex[65];
|
||
|
||
sha256(body_bytes, body_length, digest_raw);
|
||
hex_lower(digest_raw, sizeof(digest_raw), digest_hex, sizeof(digest_hex));
|
||
```
|
||
|
||
mbedTLS 示例:
|
||
|
||
```c
|
||
#include "mbedtls/sha256.h"
|
||
|
||
int make_content_sha256(
|
||
const uint8_t *body,
|
||
size_t body_len,
|
||
char output_hex[65]
|
||
) {
|
||
static const char hex[] = "0123456789abcdef";
|
||
uint8_t digest[32];
|
||
|
||
if (mbedtls_sha256_ret(body, body_len, digest, 0) != 0) {
|
||
return -1;
|
||
}
|
||
for (size_t i = 0; i < sizeof(digest); ++i) {
|
||
output_hex[i * 2] = hex[digest[i] >> 4];
|
||
output_hex[i * 2 + 1] = hex[digest[i] & 0x0f];
|
||
}
|
||
output_hex[64] = '\0';
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
GET 和真正空正文的输入长度是 0 字节,因此 SHA-256 固定为:
|
||
|
||
```text
|
||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||
```
|
||
|
||
以下内容都不是空正文,摘要与上面的固定值不同:
|
||
|
||
| 正文 | UTF-8 字节数 | 说明 |
|
||
|---|---:|---|
|
||
| 真正空正文 | 0 | 使用固定空摘要 |
|
||
| `{}` | 2 | 对字节 `7b 7d` 计算 |
|
||
| `""` | 2 | 对两个双引号字节计算 |
|
||
| `\n` | 1 | 对换行字节 `0a` 计算 |
|
||
| 一个空格 | 1 | 对字节 `20` 计算 |
|
||
|
||
JSON 中字段顺序、空格、换行、转义方式或非 ASCII 字符的 UTF-8 编码只要变化,摘要就会变化。平台比较的是原始字节,不是解析后的 JSON 对象。
|
||
|
||
#### 4.2.5 Request URI 的确定方法
|
||
|
||
签名使用 Request URI,不包含协议、域名和端口:
|
||
|
||
```text
|
||
request_uri = path + 可选的问号和原始查询串
|
||
```
|
||
|
||
示例:
|
||
|
||
```text
|
||
POST https://121.41.46.238/device/v2/conversations
|
||
签名URI:/device/v2/conversations
|
||
```
|
||
|
||
```text
|
||
GET https://121.41.46.238/device/v2/bootstrap?firmware_version=1.2.0&network=wifi
|
||
签名URI:/device/v2/bootstrap?firmware_version=1.2.0&network=wifi
|
||
```
|
||
|
||
查询参数顺序、重复参数、百分号编码、`+` 与 `%20`、十六进制转义大小写都必须与最终发送的 URI 完全一致。推荐先由 HTTP 客户端构建最终 URI,再从同一缓冲区生成签名;不能签名后重新排序查询参数。
|
||
|
||
#### 4.2.6 `X-Device-Signature`
|
||
|
||
签名原文按以下五行拼接,行与行之间只有一个字节 `0x0A`,最后一行末尾不能再添加换行:
|
||
|
||
```text
|
||
UPPERCASE_HTTP_METHOD
|
||
REQUEST_URI_WITH_EXACT_QUERY_STRING
|
||
X-Device-Timestamp原值
|
||
X-Device-Nonce原值
|
||
X-Device-Content-SHA256原值
|
||
```
|
||
|
||
计算公式:
|
||
|
||
```text
|
||
signature_raw = HMAC-SHA256(device_secret_raw_bytes, canonical_utf8_bytes)
|
||
X-Device-Signature = Base64URL_NoPadding(signature_raw)
|
||
```
|
||
|
||
Base64URL 转换要求:
|
||
|
||
1. 先得到标准 Base64;
|
||
2. 把 `+` 替换为 `-`;
|
||
3. 把 `/` 替换为 `_`;
|
||
4. 删除末尾所有 `=`;
|
||
5. 不能输出十六进制 HMAC,必须输出 Base64URL。
|
||
|
||
mbedTLS HMAC 核心示例:
|
||
|
||
```c
|
||
#include "mbedtls/md.h"
|
||
|
||
int hmac_sha256(
|
||
const uint8_t *secret,
|
||
size_t secret_len,
|
||
const uint8_t *canonical,
|
||
size_t canonical_len,
|
||
uint8_t output[32]
|
||
) {
|
||
const mbedtls_md_info_t *info =
|
||
mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||
if (info == NULL) {
|
||
return -1;
|
||
}
|
||
return mbedtls_md_hmac(
|
||
info,
|
||
secret,
|
||
secret_len,
|
||
canonical,
|
||
canonical_len,
|
||
output
|
||
);
|
||
}
|
||
```
|
||
|
||
构造签名原文时可以使用:
|
||
|
||
```c
|
||
int written = snprintf(
|
||
canonical,
|
||
sizeof(canonical),
|
||
"%s\n%s\n%s\n%s\n%s",
|
||
method_upper,
|
||
request_uri,
|
||
timestamp,
|
||
nonce,
|
||
content_sha256
|
||
);
|
||
|
||
if (written < 0 || (size_t)written >= sizeof(canonical)) {
|
||
return SIGN_BUFFER_TOO_SMALL;
|
||
}
|
||
```
|
||
|
||
`snprintf` 产生的结尾 `\0` 不能包含在 HMAC 输入长度中,HMAC 长度应为 `written`。
|
||
|
||
#### 4.2.7 可复现签名测试向量
|
||
|
||
下面的密钥仅用于验证算法,绝不能用于真实设备:
|
||
|
||
```text
|
||
device_id = 10000000-0000-0000-0000-000000000512
|
||
test_secret = embedded-doc-example-secret-32-bytes-minimum
|
||
method = POST
|
||
request_uri = /device/v2/conversations
|
||
timestamp = 2026-08-29T08:31:00.050Z
|
||
nonce = 5d4c79fa3f014e9cacb68eeb481be2c8
|
||
```
|
||
|
||
请求正文必须是下面这一行的 246 个 UTF-8 字节,末尾没有换行:
|
||
|
||
```json
|
||
{"device_time":"2026-08-29T08:31:00.000Z","monotonic_ms":15820,"capabilities":{"rtc":true,"audio_tracks":["near","far"],"upload_resume":true,"rtc_credential_renewal":true},"network":{"type":"wifi","rssi":-55},"requested_goal":"open_conversation"}
|
||
```
|
||
|
||
正文 SHA-256 应得到:
|
||
|
||
```text
|
||
6ab32d795cf68a2f48d3db879e4811db683309b7a3f631003b47e727646143c9
|
||
```
|
||
|
||
签名原文的 UTF-8 内容必须准确为:
|
||
|
||
```text
|
||
POST
|
||
/device/v2/conversations
|
||
2026-08-29T08:31:00.050Z
|
||
5d4c79fa3f014e9cacb68eeb481be2c8
|
||
6ab32d795cf68a2f48d3db879e4811db683309b7a3f631003b47e727646143c9
|
||
```
|
||
|
||
使用测试密钥计算后,`X-Device-Signature` 必须得到:
|
||
|
||
```text
|
||
_spDPNobaFkrX8hInZ26ULuoKsYhSzExpSK7Ww_cVR4
|
||
```
|
||
|
||
对应请求头为:
|
||
|
||
```http
|
||
X-Device-Id: 10000000-0000-0000-0000-000000000512
|
||
X-Device-Timestamp: 2026-08-29T08:31:00.050Z
|
||
X-Device-Nonce: 5d4c79fa3f014e9cacb68eeb481be2c8
|
||
X-Device-Content-SHA256: 6ab32d795cf68a2f48d3db879e4811db683309b7a3f631003b47e727646143c9
|
||
X-Device-Signature: _spDPNobaFkrX8hInZ26ULuoKsYhSzExpSK7Ww_cVR4
|
||
Idempotency-Key: create-51200000-0000-4000-8000-000000000001
|
||
```
|
||
|
||
嵌入式实现应先离线运行该测试向量。如果摘要或签名不一致,不要开始平台联调,应先检查正文长度、换行、URI、Base64URL 和密钥字节。
|
||
|
||
#### 4.2.8 常见计算错误
|
||
|
||
| 现象 | 常见原因 |
|
||
|---|---|
|
||
| 正文摘要不一致 | 计算后重新序列化 JSON、末尾多换行、发送长度与计算长度不同 |
|
||
| GET 签名失败 | 忘记使用空正文摘要,或签名 URI 没有包含查询串 |
|
||
| HMAC 长度不对 | 把 C 字符串结尾 `\0` 也计入 HMAC |
|
||
| 签名包含 `+`、`/`、`=` | 使用了标准 Base64,没有转换为无填充 Base64URL |
|
||
| 同一请求第一次成功、第二次 401 | 重用了同一个 Nonce,被重放保护拒绝 |
|
||
| 相同幂等操作创建了两条记录 | 重试时更换了 `Idempotency-Key` |
|
||
| `IDEMPOTENCY_KEY_REUSED` | 同一幂等键对应的正文原始字节发生变化 |
|
||
| Timestamp 过期 | 设备 UTC 未校准,或请求在离线队列停留过久 |
|
||
|
||
#### 4.2.9 一次请求的完整生成顺序
|
||
|
||
设备应严格按下面的顺序组装请求,避免参与签名的内容与实际发送内容不一致:
|
||
|
||
1. 确定 HTTP Method,并转换为大写,例如 `POST`。
|
||
2. 构建最终 Request URI;如果有查询参数,此时就完成排序和百分号编码,之后不再修改。
|
||
3. 把请求对象序列化一次,得到最终 `body_bytes` 和 `body_length`;GET 或无正文请求使用长度为 0 的字节数组。
|
||
4. 计算 `SHA256(body_bytes[0..body_length-1])`,转成 64 位小写十六进制,得到 `X-Device-Content-SHA256`。
|
||
5. 读取已经校准的 UTC 时间,生成 `X-Device-Timestamp`。
|
||
6. 从 TRNG/CSPRNG 读取随机数,生成新的 `X-Device-Nonce`。
|
||
7. 用 Method、Request URI、Timestamp、Nonce 和正文摘要构造五行签名原文。
|
||
8. 使用当前设备的 `device_secret` 计算 HMAC-SHA256,并转换成无填充 Base64URL,得到 `X-Device-Signature`。
|
||
9. 对要求幂等的接口,读取本次业务操作已经持久化的 `Idempotency-Key`;首次操作才生成新值。
|
||
10. 设置 HTTP 请求头并发送第 3 步保存的同一份 `body_bytes`,发送长度必须是同一个 `body_length`。
|
||
|
||
推荐把“最终 URI、最终正文缓冲区、五个签名请求头”封装成不可变请求对象。请求进入发送阶段后不得再修改 URI 或正文。
|
||
|
||
重试时按错误发生位置处理:
|
||
|
||
| 场景 | 正文 | `Idempotency-Key` | Timestamp | Nonce | Signature |
|
||
|---|---|---|---|---|---|
|
||
| 同一业务操作因超时/断网重试 | 保持完全相同 | 保持相同 | 重新生成 | 重新生成 | 重新计算 |
|
||
| 用户发起新的业务操作 | 生成新正文 | 生成新值 | 重新生成 | 重新生成 | 重新计算 |
|
||
|
||
#### 4.2.10 其他相关 HTTP 请求头
|
||
|
||
JSON 请求还应设置:
|
||
|
||
```http
|
||
Content-Type: application/json; charset=utf-8
|
||
Content-Length: 246
|
||
```
|
||
|
||
- `Content-Length` 是最终正文的字节数,不是字符数。测试向量正文是 246 个 UTF-8 字节,所以值为 `246`。
|
||
- 如果 HTTP 库自动生成 `Content-Length`,设备只需保证传给 HTTP 库的缓冲区和长度就是参与 SHA-256 的同一份数据。
|
||
- GET/无正文请求正文长度为 0;HTTP 库可以发送 `Content-Length: 0`,也可以省略该头,二者都不改变空正文摘要。
|
||
- `Content-Type`、`Content-Length` 和 `Idempotency-Key` 不属于五行 HMAC 签名原文,不能擅自把它们加入签名字符串。
|
||
- 所有设备接口都必须使用 HTTPS;HMAC 不能替代 TLS 证书校验。
|
||
|
||
### 4.3 设备 `...0512` 的密钥来源与保存
|
||
|
||
本地环境通过 `DEVICE_LOCAL_SECRET` 把演示密钥导入 `device_credential` 表;服务端数据库保存 AES-256-GCM 加密后的密文,不保存明文。公网或生产环境应使用 `device-credential provision/rotate` 为设备生成或轮换独立密钥。
|
||
|
||
嵌入式设备应在生产或受控测试烧录阶段接收设备 `...0512` 对应的明文密钥,并写入设备安全区。设备 ID 与密钥必须成对保存。明文密钥不能写进本文档、普通 Flash 分区、串口日志、崩溃转储或升级包;如果密钥丢失或疑似泄露,应在平台执行轮换,不能从数据库反查明文。
|
||
|
||
### 4.4 幂等键
|
||
|
||
以下接口要求 `Idempotency-Key`:
|
||
|
||
- 创建通话;
|
||
- 激活通话;
|
||
- 字幕批量补传;
|
||
- 停止通话;
|
||
- 创建录音上传批次。
|
||
|
||
规则:
|
||
|
||
1. 长度 16~128 字符。
|
||
2. 同一次业务操作重试时保持原键和原正文。
|
||
3. 每一次 HTTP 重试仍要生成新的 Timestamp、Nonce 和签名。
|
||
4. 相同键和相同正文返回原结果,响应可能带 `Idempotency-Replayed: true`。
|
||
5. 相同键对应不同正文返回 `409 IDEMPOTENCY_KEY_REUSED`。
|
||
|
||
推荐生成方法:首次发起一项业务操作时,从 TRNG/CSPRNG 读取 16 个随机字节,编码为 32 个小写十六进制字符,再加上便于排查的接口前缀:
|
||
|
||
```c
|
||
uint8_t operation_random[16];
|
||
char operation_hex[33];
|
||
char idempotency_key[64];
|
||
|
||
secure_random(rng, operation_random, sizeof(operation_random));
|
||
hex_lower(operation_random, sizeof(operation_random),
|
||
operation_hex, sizeof(operation_hex));
|
||
snprintf(idempotency_key, sizeof(idempotency_key),
|
||
"create-%s", operation_hex);
|
||
```
|
||
|
||
示例:
|
||
|
||
```text
|
||
create-51200000000040008000000000000001
|
||
```
|
||
|
||
前缀建议按接口区分:`create-`、`activate-`、`turns-`、`stop-`、`upload-`。前缀只是便于日志定位,不参与业务判断;整个字符串必须满足 16~128 字符。
|
||
|
||
设备在首次发送前必须把下面的数据写入掉电可恢复存储:
|
||
|
||
- 业务操作类型;
|
||
- `Idempotency-Key`;
|
||
- 最终正文原始字节或能够逐字节恢复同一正文的数据;
|
||
- 当前操作状态。
|
||
|
||
收到明确的成功响应或明确的不可重试失败后,才能结束该幂等操作。HTTP 超时、连接断开或未收到完整响应时,结果属于“未知”,设备应使用原 `Idempotency-Key` 和原正文重试,但必须重新生成 Timestamp、Nonce 和 Signature。
|
||
|
||
不要使用 Nonce 作为幂等键,也不要从 Timestamp 推导幂等键:Nonce 每次 HTTP 尝试都要变化,而幂等键在同一业务操作的所有重试中必须稳定。`Idempotency-Key` 也不属于五行 HMAC 签名原文。
|
||
|
||
## 5. 阶段一:启动登记、配置同步和网络准备
|
||
|
||
设备开机、网络恢复或固件升级后,应先完成平台启动登记和配置同步,再开放通话能力。
|
||
|
||
### 5.1 设备启动登记
|
||
|
||
```http
|
||
GET /device/v2/bootstrap
|
||
```
|
||
|
||
#### 查询参数
|
||
|
||
| 参数 | 必填 | 嵌入式示例值 | 说明 |
|
||
|---|---:|---|---|
|
||
| `firmware_version` | 是 | `1.2.0` | 当前固件版本 |
|
||
| `board_rev` | 否 | `board-rev-a` | PCB/硬件版本 |
|
||
| `rtc_sdk_version` | 否 | `embedded-rtc-3.0.0` | 设备集成的 RTC SDK 版本 |
|
||
| `codec` | 否 | `opus` | RTC 音频编码能力 |
|
||
| `sample_rate` | 否 | `16000` | 当前首选采样率 |
|
||
| `network` | 否 | `wifi` | 当前网络类型或描述 |
|
||
| `device_time` | 是 | 当前 UTC 时间 | RFC3339 |
|
||
|
||
#### 请求示例
|
||
|
||
```http
|
||
GET /device/v2/bootstrap?firmware_version=1.2.0&board_rev=board-rev-a&rtc_sdk_version=embedded-rtc-3.0.0&codec=opus&sample_rate=16000&network=wifi&device_time=2026-08-29T08%3A30%3A00.000Z HTTP/1.1
|
||
Host: 121.41.46.238
|
||
X-Device-Id: 10000000-0000-0000-0000-000000000512
|
||
X-Device-Timestamp: 2026-08-29T08:30:00.050Z
|
||
X-Device-Nonce: 5d4c79fa3f014e9cacb68eeb481be2c8
|
||
X-Device-Content-SHA256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||
X-Device-Signature: <按包含完整查询串的Request URI计算>
|
||
```
|
||
|
||
#### 本地种子中未绑定设备的响应示例
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512BOOTSTRAP",
|
||
"data": {
|
||
"server_time": "2026-08-29T08:30:00.080Z",
|
||
"clock_skew_ms": 80,
|
||
"device": {
|
||
"device_id": "10000000-0000-0000-0000-000000000512",
|
||
"status": "registered",
|
||
"model": "simulated-terminal",
|
||
"hardware_revision": "sim-rev-a",
|
||
"minimum_security_version": "dev-signature-v1",
|
||
"minimum_firmware_version": "0.1.0-sim",
|
||
"firmware_compatible": true,
|
||
"config_revision": 1,
|
||
"binding": {
|
||
"bound": false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
设备应检查:
|
||
|
||
- `status` 必须为 `registered`;
|
||
- `firmware_compatible` 必须为 `true`;
|
||
- 正常创建通话前 `binding.bound` 应为 `true`;
|
||
- 使用 `server_time` 和 `clock_skew_ms` 检测本机时钟偏差。
|
||
|
||
主要状态码:`200`、`400`、`401`、`503`。
|
||
|
||
### 5.2 获取设备配置
|
||
|
||
```http
|
||
GET /device/v2/config
|
||
```
|
||
|
||
设备应持久化上一次成功配置的强 ETag,并在后续请求中发送 `If-None-Match`,以减少响应流量。首次启动或没有缓存时可以省略。
|
||
|
||
#### 请求示例
|
||
|
||
```http
|
||
GET /device/v2/config HTTP/1.1
|
||
Host: 121.41.46.238
|
||
X-Device-Id: 10000000-0000-0000-0000-000000000512
|
||
X-Device-Timestamp: 2026-08-29T08:30:00.100Z
|
||
X-Device-Nonce: c84bdfef37594423a62f8ac870c27f10
|
||
X-Device-Content-SHA256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||
X-Device-Signature: <计算结果>
|
||
If-None-Match: "v1"
|
||
```
|
||
|
||
#### 响应示例
|
||
|
||
```http
|
||
HTTP/1.1 200 OK
|
||
ETag: "v1"
|
||
Content-Type: application/json; charset=utf-8
|
||
|
||
{
|
||
"request_id": "01J512CONFIG",
|
||
"data": {
|
||
"device_id": "10000000-0000-0000-0000-000000000512",
|
||
"revision": 1,
|
||
"desired_config": {
|
||
"codec": "opus",
|
||
"sample_rate": 16000,
|
||
"heartbeat_interval_seconds": 15
|
||
},
|
||
"binding": {
|
||
"bound": false
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
配置未变化且客户端发送匹配的 `If-None-Match` 时返回 `304`,无响应正文。平台暂时不可用时,设备应保留上一次有效配置。
|
||
|
||
### 5.3 火山 RTC 网络探测
|
||
|
||
完成两个平台接口后,设备可以按网络栈和 RTC SDK 能力预检下列火山 RTC 域名的 443 端口:
|
||
|
||
```text
|
||
access.rtc.volcvideo.com
|
||
access2-hl.rtc.volcvideo.com
|
||
common.rtc.volcvideo.com
|
||
rtcg-access.volcvideos.com
|
||
rtcg.volcvideos.com
|
||
web-log-report.rtc.volcvideo.com
|
||
web-log-report.volcvideos.com
|
||
```
|
||
|
||
这一步不是平台 HTTP API。产品可以将预检失败作为阻止通话的条件,也可以交由 RTC SDK 在入房阶段返回网络错误;无论采用哪种方式,都应提供稳定错误码而不是上传网络堆栈或密钥。
|
||
|
||
## 6. 阶段二:创建通话并等待智能体
|
||
|
||
以下成功示例假设设备 `...0512` 已经完成绑定并具有有效语音通话授权。
|
||
|
||
### 6.1 创建通话
|
||
|
||
```http
|
||
POST /device/v2/conversations
|
||
```
|
||
|
||
本接口只分配 RTC 房间和短期凭证,不启动豆包智能体。
|
||
|
||
#### 请求字段
|
||
|
||
| 字段 | 必填 | 嵌入式实现要求或示例 |
|
||
|---|---:|---|
|
||
| `device_time` | 是 | 当前 UTC RFC3339 |
|
||
| `monotonic_ms` | 否 | RTOS/系统启动后的单调毫秒计数,≥0 |
|
||
| `capabilities.rtc` | 是 | `true` |
|
||
| `capabilities.audio_tracks` | 是 | `['near','far']` |
|
||
| `capabilities.upload_resume` | 是 | `true` |
|
||
| `capabilities.rtc_credential_renewal` | 否 | `true` |
|
||
| `network.type` | 是 | 示例为 `wifi`,也可为 `ethernet`、`4g` 等 |
|
||
| `network.rssi` | 否 | 无线网络建议上报,例如 `-55` |
|
||
| `requested_goal` | 是 | 固定 `open_conversation` |
|
||
|
||
`audio_tracks` 是设备能力声明,不是本次通话的录音授权:
|
||
|
||
| 值 | 含义 | 嵌入式音频来源 |
|
||
|---|---|---|
|
||
| `near` | 设备近端轨道 | 麦克风采集的用户语音 |
|
||
| `far` | 设备远端轨道 | RTC 解码后的智能体下行语音 |
|
||
|
||
设备可以声明 `['near']`、`['far']` 或 `['near','far']`,至少一项且不能重复。即使设备声明支持某轨道,也只有创建响应中的 `privacy.record_near`、`privacy.record_far` 和 `privacy.upload_allowed` 同时允许时,才能采集、落盘和上传对应录音。
|
||
|
||
额外请求头:
|
||
|
||
```http
|
||
Idempotency-Key: create-<设备生成的稳定业务UUID>
|
||
```
|
||
|
||
#### 请求正文示例
|
||
|
||
```json
|
||
{
|
||
"device_time": "2026-08-29T08:31:00.000Z",
|
||
"monotonic_ms": 15820,
|
||
"capabilities": {
|
||
"rtc": true,
|
||
"audio_tracks": ["near", "far"],
|
||
"upload_resume": true,
|
||
"rtc_credential_renewal": true
|
||
},
|
||
"network": {
|
||
"type": "wifi",
|
||
"rssi": -55
|
||
},
|
||
"requested_goal": "open_conversation"
|
||
}
|
||
```
|
||
|
||
#### 201 响应示例
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512CREATE",
|
||
"data": {
|
||
"conversation_id": "51200000-0000-4000-8000-000000000001",
|
||
"status": "ROOM_ISSUED",
|
||
"rtc": {
|
||
"app_id": "<rtc-app-id>",
|
||
"room_id": "r_51200000000040008000000000000001",
|
||
"user_id": "d_10000000000000000000000000000512",
|
||
"agent_user_id": "a_51200000000040008000000000000001",
|
||
"token": "<short-lived-rtc-token>",
|
||
"expires_at": "2026-08-29T08:36:00Z"
|
||
},
|
||
"privacy": {
|
||
"retention_mode": "transcript_and_audio",
|
||
"record_near": true,
|
||
"record_far": true,
|
||
"upload_allowed": true
|
||
},
|
||
"session": {
|
||
"engine_mode": "s2s_hybrid",
|
||
"heartbeat_interval_sec": 15,
|
||
"allocation_expires_at": "2026-08-29T08:33:00Z"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
关键处理:
|
||
|
||
- `rtc.token` 只保存在内存中;
|
||
- 必须在 `allocation_expires_at` 之前入房并激活;
|
||
- `privacy` 决定设备是否允许采集和保存 near/far 原始音频;
|
||
- `engine_mode` 只用于诊断,设备不能据此改变音频逻辑。
|
||
|
||
主要错误:
|
||
|
||
| HTTP | 错误码 | 含义 |
|
||
|---:|---|---|
|
||
| 400 | `VALIDATION_FAILED` | 字段、正文或幂等键不符合契约 |
|
||
| 401 | `DEVICE_AUTHENTICATION_FAILED` | 设备 ID、时间、摘要、密钥或签名不正确 |
|
||
| 403 | `CONSENT_REQUIRED` | 缺少语音通话授权 |
|
||
| 409 | `DEVICE_NOT_BOUND` | 设备未绑定;本地种子中的 `...0512` 默认会命中此错误 |
|
||
| 409 | `CONVERSATION_ALREADY_ACTIVE` | 设备已有未结束通话 |
|
||
| 409 | `IDEMPOTENCY_KEY_REUSED` | 同一键用于不同正文 |
|
||
| 422 | `AGENT_CONTEXT_PREPARATION_FAILED` | 背景资料准备失败,不会创建 RTC 房间 |
|
||
| 426 | `FIRMWARE_UPDATE_REQUIRED` | 固件低于最低版本 |
|
||
| 429 | `USAGE_QUOTA_EXCEEDED` | 费用、用量或并发额度不足 |
|
||
| 503 | `RUNTIME_CONFIG_UNAVAILABLE` | 没有可用语音路由配置,可按 `retryable` 退避重试 |
|
||
|
||
### 6.2 嵌入式设备直接加入火山 RTC
|
||
|
||
创建成功后,设备不经过平台传输实时音频,而是直接调用火山 RTC SDK:
|
||
|
||
1. 使用 `rtc.app_id` 创建 RTC Engine;
|
||
2. 初始化麦克风、音频编解码和播放链路;
|
||
3. 使用 `rtc.token + rtc.room_id + rtc.user_id` 加入房间;
|
||
4. 发布近端音频;
|
||
5. 订阅并播放智能体远端音频;
|
||
6. 如果设备侧承担最终字幕采集,则启动 RTC 字幕/ASR 并确认可用;
|
||
7. 只有设备要求“字幕可用后才能通话”时,字幕失败才阻止激活;具体门禁由产品方案确定。
|
||
|
||
实时麦克风和豆包音频不经过平台 API 或数据库。
|
||
|
||
### 6.3 确认入房并启动智能体
|
||
|
||
```http
|
||
POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/activate
|
||
```
|
||
|
||
只有 RTC 入房和字幕启动成功后调用。
|
||
|
||
额外请求头:
|
||
|
||
```http
|
||
Idempotency-Key: activate-<设备生成的稳定业务UUID>
|
||
```
|
||
|
||
#### 请求正文示例
|
||
|
||
```json
|
||
{
|
||
"joined_at": "2026-08-29T08:31:03.000Z",
|
||
"room_id": "r_51200000000040008000000000000001",
|
||
"rtc_user_id": "d_10000000000000000000000000000512",
|
||
"rtc_join_result": "success",
|
||
"config_revision": 1,
|
||
"connection_reference": "embedded-rtc-session-0001"
|
||
}
|
||
```
|
||
|
||
#### 202 响应示例
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512ACTIVATE",
|
||
"data": {
|
||
"conversation_id": "51200000-0000-4000-8000-000000000001",
|
||
"status": "STARTING_AGENT",
|
||
"accepted_at": "2026-08-29T08:31:03.100Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
`202 STARTING_AGENT` 只表示平台已经受理。平台随后通过可靠任务让 Worker 调用豆包 `StartVoiceChat`。
|
||
|
||
主要错误:`400`、`401`、`404 CONVERSATION_NOT_FOUND`、`409 ROOM_ALLOCATION_EXPIRED`、`409 RTC_ALLOCATION_MISMATCH`、`409 CONFIG_VERSION_CONFLICT`、`409 CONVERSATION_STATE_CONFLICT`、`503`。
|
||
|
||
### 6.4 轮询通话状态
|
||
|
||
```http
|
||
GET /device/v2/conversations/51200000-0000-4000-8000-000000000001
|
||
```
|
||
|
||
推荐在激活后约 500 ms 首次查询,之后每 1 秒查询,并确保总等待时间不超过 `session.allocation_expires_at`。设备应设置有限次数和本地超时,不能无限轮询。
|
||
|
||
#### 等待响应
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512STATE1",
|
||
"data": {
|
||
"conversation_id": "51200000-0000-4000-8000-000000000001",
|
||
"status": "STARTING_AGENT",
|
||
"action": "wait"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 激活响应
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512STATE2",
|
||
"data": {
|
||
"conversation_id": "51200000-0000-4000-8000-000000000001",
|
||
"status": "ACTIVE",
|
||
"action": "continue"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 失败响应
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512STATE3",
|
||
"data": {
|
||
"conversation_id": "51200000-0000-4000-8000-000000000001",
|
||
"status": "FAILED",
|
||
"action": "leave_room",
|
||
"failure_code": "provider_start_rejected"
|
||
}
|
||
}
|
||
```
|
||
|
||
| `action` | 设备动作 |
|
||
|---|---|
|
||
| `wait` | 保持 RTC 房间并继续有限轮询 |
|
||
| `continue` | 进入正常通话并启动心跳 |
|
||
| `leave_room` | 退出 RTC 并执行失败清理 |
|
||
|
||
## 7. 阶段三:通话期间
|
||
|
||
### 7.1 通话心跳
|
||
|
||
```http
|
||
POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/heartbeat
|
||
```
|
||
|
||
设备进入 `ACTIVE` 后应立即发送一次,之后按:
|
||
|
||
```text
|
||
max(5秒, session.heartbeat_interval_sec)
|
||
```
|
||
|
||
周期调用。当前通常为 15 秒。
|
||
|
||
#### 请求正文示例
|
||
|
||
```json
|
||
{
|
||
"sequence_no": 1,
|
||
"device_time": "2026-08-29T08:31:05.000Z",
|
||
"monotonic_ms": 20820,
|
||
"conversation_status": "ACTIVE",
|
||
"rtc": {
|
||
"joined": true,
|
||
"uplink": true,
|
||
"downlink": true
|
||
},
|
||
"network": {
|
||
"type": "wifi",
|
||
"rssi": -55
|
||
},
|
||
"firmware_version": "1.2.0",
|
||
"reported_config_revision": 1,
|
||
"rtc_credential_expires_at": "2026-08-29T08:36:00Z",
|
||
"recording_queue": {
|
||
"pending_segments": 0,
|
||
"failed_segments": 0
|
||
},
|
||
"command_results": []
|
||
}
|
||
```
|
||
|
||
#### 200 响应示例
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512HEARTBEAT",
|
||
"data": {
|
||
"lease_expires_at": "2026-08-29T08:31:50Z",
|
||
"actions": [
|
||
{
|
||
"type": "continue"
|
||
}
|
||
],
|
||
"desired_config_revision": 1,
|
||
"commands": []
|
||
}
|
||
}
|
||
```
|
||
|
||
可能的动作:
|
||
|
||
| 动作 | 设备处理 |
|
||
|---|---|
|
||
| `continue` | 继续通话 |
|
||
| `stop` | 尽快停止并离房 |
|
||
| `stop_after_turn` | 当前话轮完成后停止 |
|
||
| `renew_rtc_credential` | 使用动作中的新 `rtc.token` 调用 RTC SDK 更新 Token,并保存新到期时间 |
|
||
|
||
心跳 `sequence_no` 从 1 开始严格递增,设备重启后应从耐久确认点恢复。重复或乱序心跳不会延长租约。该接口不要求 `Idempotency-Key`,由序号完成去重。
|
||
|
||
### 7.2 批量上传最终字幕
|
||
|
||
```http
|
||
POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/turns:batch
|
||
```
|
||
|
||
如果设备 RTC SDK 或本地 ASR 能产生最终字幕,设备应在收到最终结果后加入可靠队列并调用该接口。每批最多 500 条;不具备字幕能力的固件可以不调用,但应与平台确认最终转写由供应商回调提供。
|
||
|
||
推荐生成规则:
|
||
|
||
- `turn_key = rtc-subtitle-{speaker}-{RTC sequence}`;
|
||
- `sequence_no` 为本通话内从 1 开始的严格递增编号;
|
||
- `speaker` 为 `subject` 或 `agent`;
|
||
- 幂等键可以使用 `turns-{请求正文SHA-256前32位}`。
|
||
|
||
#### 请求正文示例
|
||
|
||
```json
|
||
{
|
||
"turns": [
|
||
{
|
||
"turn_key": "rtc-subtitle-subject-1001",
|
||
"sequence_no": 1,
|
||
"speaker": "subject",
|
||
"start_at": "2026-08-29T08:31:20.000Z",
|
||
"end_at": "2026-08-29T08:31:23.000Z",
|
||
"final_text": "给我讲一个小时候的故事。",
|
||
"interrupted": false
|
||
},
|
||
{
|
||
"turn_key": "rtc-subtitle-agent-2001",
|
||
"sequence_no": 2,
|
||
"speaker": "agent",
|
||
"start_at": "2026-08-29T08:31:24.000Z",
|
||
"end_at": "2026-08-29T08:31:30.000Z",
|
||
"final_text": "好呀,我们从您小时候生活的地方说起。",
|
||
"interrupted": false
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
#### 200 响应示例
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512TURNS",
|
||
"data": {
|
||
"conversation_id": "51200000-0000-4000-8000-000000000001",
|
||
"accepted_count": 2,
|
||
"duplicate_count": 0
|
||
}
|
||
}
|
||
```
|
||
|
||
相同终稿重放会计入 `duplicate_count`。相同 `turn_key` 或 `sequence_no` 对应不同内容时,整批返回 `409 TRANSCRIPT_TURN_CONFLICT`。
|
||
|
||
上传失败时设备应保留可靠队列,并在以下时机重试:
|
||
|
||
- 下一次心跳之后;
|
||
- 结束通话之前,最多连续重试 3 次。
|
||
|
||
如果产品要求“字幕不丢失”,仍有字幕未上传时设备不应直接清空队列;可以延迟结束流程或把加密队列持久化后异步补传。
|
||
|
||
## 8. 阶段四:结束通话
|
||
|
||
### 8.1 本地结束前处理
|
||
|
||
用户挂断、平台下发停止动作、RTC 失败或设备策略触发结束时,设备建议按以下顺序处理:
|
||
|
||
1. 停止心跳定时器;
|
||
2. 停止 RTC 实时字幕;
|
||
3. 等待音频/字幕管线吐出最后的最终结果;
|
||
4. 按本地重试策略上传剩余最终字幕;
|
||
5. 将 near/far PCM 音频封装为单声道 WAV PCM16 分片;
|
||
6. 退出 RTC 房间、停止采集并销毁 RTC Engine;
|
||
7. 然后调用平台停止接口。
|
||
|
||
### 8.2 停止通话
|
||
|
||
```http
|
||
POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/stop
|
||
```
|
||
|
||
额外请求头:
|
||
|
||
```http
|
||
Idempotency-Key: stop-<设备生成的稳定业务UUID>
|
||
```
|
||
|
||
#### 请求正文示例
|
||
|
||
```json
|
||
{
|
||
"reason": "user_hangup",
|
||
"device_time": "2026-08-29T08:35:00.000Z",
|
||
"last_heartbeat_sequence": 16,
|
||
"rtc_left": true,
|
||
"recording": {
|
||
"near_segments": 1,
|
||
"far_segments": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 202 响应示例
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512STOP",
|
||
"data": {
|
||
"conversation_id": "51200000-0000-4000-8000-000000000001",
|
||
"status": "STOPPING",
|
||
"accepted_at": "2026-08-29T08:35:00.100Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
响应状态可能为 `STOPPING`、`COMPLETED`、`FAILED` 或 `EXPIRED`。相同停止请求重放不会重复触发供应商 Stop。
|
||
|
||
启动流程任何一步失败时,只要已经拿到 `conversation_id`,设备也应释放 RTC,并使用稳定原因码清理通话,例如:
|
||
|
||
```text
|
||
reason = device_start_failed
|
||
```
|
||
|
||
### 8.3 等待通话终态
|
||
|
||
仅当本次实际生成了录音分片时,设备需要等待通话进入允许上传的终态:
|
||
|
||
```http
|
||
GET /device/v2/conversations/51200000-0000-4000-8000-000000000001
|
||
```
|
||
|
||
建议每 1 秒查询一次,并设置不超过 60 秒的本地等待上限:
|
||
|
||
- `COMPLETED` 或 `FAILED`:进入录音上传;
|
||
- `EXPIRED`:停止上传并报错;
|
||
- 超时:本次录音上传失败。
|
||
|
||
## 9. 阶段五:录音上传和校验
|
||
|
||
只有创建通话响应满足以下条件时,设备才允许采集和上传录音:
|
||
|
||
```text
|
||
privacy.upload_allowed = true
|
||
privacy.record_near = true 或 privacy.record_far = true
|
||
```
|
||
|
||
当前平台接受的嵌入式录音基线:
|
||
|
||
| 项目 | 当前值 |
|
||
|---|---|
|
||
| 编码 | `wav_pcm_s16le` |
|
||
| Content-Type | `audio/wav` |
|
||
| 声道 | 单声道 |
|
||
| near | 设备麦克风原始音频帧 |
|
||
| far | 智能体远端原始音频帧 |
|
||
| 单片最大时长 | 9 分钟,低于平台 10 分钟上限 |
|
||
| 单片最大大小 | 64 MiB |
|
||
|
||
### 9.1 创建上传批次
|
||
|
||
```http
|
||
POST /device/v2/conversations/51200000-0000-4000-8000-000000000001/upload-batches
|
||
```
|
||
|
||
设备先对每个本地 WAV 分片计算真实字节数和小写 SHA-256,然后只把清单提交给平台,不把音频字节发送给业务 API。
|
||
|
||
幂等键:
|
||
|
||
```text
|
||
media-{清单JSON的SHA-256前40位}
|
||
```
|
||
|
||
#### 请求正文示例
|
||
|
||
```json
|
||
{
|
||
"segments": [
|
||
{
|
||
"track": "near",
|
||
"sequence_no": 0,
|
||
"codec": "wav_pcm_s16le",
|
||
"content_type": "audio/wav",
|
||
"duration_ms": 239000,
|
||
"expected_size": 7648044,
|
||
"expected_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||
},
|
||
{
|
||
"track": "far",
|
||
"sequence_no": 0,
|
||
"codec": "wav_pcm_s16le",
|
||
"content_type": "audio/wav",
|
||
"duration_ms": 238500,
|
||
"expected_size": 7632044,
|
||
"expected_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
#### 201 响应示例
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512MEDIA1",
|
||
"data": {
|
||
"id": "51200000-0000-4000-8000-000000000101",
|
||
"conversation_id": "51200000-0000-4000-8000-000000000001",
|
||
"status": "UPLOADING",
|
||
"segment_count": 2,
|
||
"expected_total_size": 15280088,
|
||
"verified_count": 0,
|
||
"rejected_count": 0,
|
||
"verification_attempts": 0,
|
||
"expires_at": "2026-08-29T09:05:00Z",
|
||
"segments": [
|
||
{
|
||
"id": "51200000-0000-4000-8000-000000000201",
|
||
"track": "near",
|
||
"sequence_no": 0,
|
||
"codec": "wav_pcm_s16le",
|
||
"content_type": "audio/wav",
|
||
"duration_ms": 239000,
|
||
"expected_size": 7648044,
|
||
"expected_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||
"status": "AUTHORIZED",
|
||
"upload_url": "https://object-store.example/temporary/near.wav?<signed-query>",
|
||
"upload_until": "2026-08-29T08:55:00Z"
|
||
},
|
||
{
|
||
"id": "51200000-0000-4000-8000-000000000202",
|
||
"track": "far",
|
||
"sequence_no": 0,
|
||
"codec": "wav_pcm_s16le",
|
||
"content_type": "audio/wav",
|
||
"duration_ms": 238500,
|
||
"expected_size": 7632044,
|
||
"expected_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||
"status": "AUTHORIZED",
|
||
"upload_url": "https://object-store.example/temporary/far.wav?<signed-query>",
|
||
"upload_until": "2026-08-29T08:55:00Z"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
主要错误:`400`、`401`、`403`(通话、归属或录音授权不允许)、`409`(幂等键或分片元数据冲突)。
|
||
|
||
### 9.2 直接 PUT 到对象存储
|
||
|
||
设备对每个响应中的新分片执行:
|
||
|
||
```http
|
||
PUT <segment.upload_url> HTTP/1.1
|
||
Content-Type: audio/wav
|
||
|
||
<该分片原始WAV字节>
|
||
```
|
||
|
||
注意:
|
||
|
||
- 这是设备到对象存储的直接请求,不经过平台业务 API;
|
||
- 不携带设备 HMAC 请求头;
|
||
- `upload_url` 自带短期签名,只能上传对应单一对象;
|
||
- 不得持久化或记录完整 `upload_url`;
|
||
- 返回非 2xx 时,本次上传流程失败并按外层逻辑重试。
|
||
|
||
### 9.3 声明上传完成
|
||
|
||
```http
|
||
POST /device/v2/upload-batches/51200000-0000-4000-8000-000000000101/complete
|
||
```
|
||
|
||
该接口没有请求正文。设备应发送真正的空正文,并使用空正文 SHA-256:
|
||
|
||
```text
|
||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||
```
|
||
|
||
#### 202 响应示例
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512MEDIA2",
|
||
"data": {
|
||
"id": "51200000-0000-4000-8000-000000000101",
|
||
"conversation_id": "51200000-0000-4000-8000-000000000001",
|
||
"status": "VERIFYING",
|
||
"segment_count": 2,
|
||
"expected_total_size": 15280088,
|
||
"verified_count": 0,
|
||
"rejected_count": 0,
|
||
"verification_attempts": 0,
|
||
"expires_at": "2026-08-29T09:05:00Z",
|
||
"segments": [
|
||
{
|
||
"id": "51200000-0000-4000-8000-000000000201",
|
||
"track": "near",
|
||
"sequence_no": 0,
|
||
"codec": "wav_pcm_s16le",
|
||
"content_type": "audio/wav",
|
||
"duration_ms": 239000,
|
||
"expected_size": 7648044,
|
||
"expected_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||
"status": "VERIFYING"
|
||
},
|
||
{
|
||
"id": "51200000-0000-4000-8000-000000000202",
|
||
"track": "far",
|
||
"sequence_no": 0,
|
||
"codec": "wav_pcm_s16le",
|
||
"content_type": "audio/wav",
|
||
"duration_ms": 238500,
|
||
"expected_size": 7632044,
|
||
"expected_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||
"status": "VERIFYING"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
重复调用不会重复创建后台校验任务。主要状态码:`202`、`401`、`404`、`409`、`410`。
|
||
|
||
### 9.4 查询校验结果
|
||
|
||
```http
|
||
GET /device/v2/upload-batches/51200000-0000-4000-8000-000000000101
|
||
```
|
||
|
||
建议每 1~2 秒查询一次,直到批次进入终态、达到 `expires_at`,或达到设备本地等待上限(建议不超过 90 秒)。
|
||
|
||
#### 完成响应示例
|
||
|
||
```json
|
||
{
|
||
"request_id": "01J512MEDIA3",
|
||
"data": {
|
||
"id": "51200000-0000-4000-8000-000000000101",
|
||
"conversation_id": "51200000-0000-4000-8000-000000000001",
|
||
"status": "COMPLETED",
|
||
"segment_count": 2,
|
||
"expected_total_size": 15280088,
|
||
"verified_count": 2,
|
||
"rejected_count": 0,
|
||
"verification_attempts": 1,
|
||
"expires_at": "2026-08-29T09:05:00Z",
|
||
"segments": [
|
||
{
|
||
"id": "51200000-0000-4000-8000-000000000201",
|
||
"track": "near",
|
||
"sequence_no": 0,
|
||
"codec": "wav_pcm_s16le",
|
||
"content_type": "audio/wav",
|
||
"duration_ms": 239000,
|
||
"expected_size": 7648044,
|
||
"expected_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||
"status": "VERIFIED",
|
||
"verified_at": "2026-08-29T08:35:05Z",
|
||
"local_delete_after": "2026-08-29T08:40:05Z"
|
||
},
|
||
{
|
||
"id": "51200000-0000-4000-8000-000000000202",
|
||
"track": "far",
|
||
"sequence_no": 0,
|
||
"codec": "wav_pcm_s16le",
|
||
"content_type": "audio/wav",
|
||
"duration_ms": 238500,
|
||
"expected_size": 7632044,
|
||
"expected_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||
"status": "VERIFIED",
|
||
"verified_at": "2026-08-29T08:35:05Z",
|
||
"local_delete_after": "2026-08-29T08:40:05Z"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
遇到以下结果时设备应判定上传失败:
|
||
|
||
- 批次状态为 `FAILED` 或 `EXPIRED`;
|
||
- 任一分片状态为 `REJECTED`;
|
||
- 等待超过 90 秒。
|
||
|
||
### 9.5 删除本地录音密文并回报
|
||
|
||
每个分片达到 `VERIFIED` 后,平台返回 `local_delete_after`。设备必须等到该时间之后,才能删除本地加密录音副本,并调用:
|
||
|
||
```http
|
||
POST /device/v2/media-segments/51200000-0000-4000-8000-000000000201/local-delete-ack
|
||
```
|
||
|
||
删除成功:
|
||
|
||
```json
|
||
{
|
||
"status": "DELETED"
|
||
}
|
||
```
|
||
|
||
删除失败:
|
||
|
||
```json
|
||
{
|
||
"status": "FAILED",
|
||
"error_code": "LOCAL_STORAGE_DELETE_FAILED"
|
||
}
|
||
```
|
||
|
||
`DELETED` 重复回报是幂等的。过早回报、分片尚未验证或状态冲突返回 `409`。此接口只证明设备副本已删除,不删除云端已验证对象。
|
||
|
||
## 10. 重试和异常处理汇总
|
||
|
||
| 场景 | 嵌入式设备建议行为 |
|
||
|---|---|
|
||
| 平台登记或配置同步失败 | 保持不可通话状态,记录稳定错误码并按错误策略重试 |
|
||
| RTC 网络不可达 | 保持不可通话状态,记录稳定网络错误码并按网络恢复策略处理 |
|
||
| 创建后 RTC/激活失败 | 释放 RTC;已有通话编号时调用 `/stop`,原因使用稳定错误码 |
|
||
| 等待 ACTIVE 超过房间预留期或本地上限 | 作为启动失败清理通话 |
|
||
| 心跳失败 | 保存待确认序号和状态,按退避策略继续;不得用旧序号覆盖新状态 |
|
||
| 字幕上传失败 | 保留可靠队列,在网络恢复、后续心跳或结束流程中重试 |
|
||
| 结束时字幕仍上传失败 | 按产品留存策略延迟结束,或持久化加密队列后异步补传 |
|
||
| 录音上传失败 | 保留本地加密分片,执行有上限的退避重试或进入补偿队列 |
|
||
| 等待通话终态超过本地上限 | 保留加密录音并进入受控补偿或失败队列 |
|
||
| 等待录音校验超过批次或本地上限 | 保留加密录音并进入受控补偿或失败队列 |
|
||
|
||
通用重试原则:
|
||
|
||
- `400/401/403/404/409/422/426` 通常不能盲目重试,应先修正状态或参数;
|
||
- `429` 按 `Retry-After` 或业务额度提示处理;
|
||
- `503` 且 `error.retryable=true` 时使用指数退避;
|
||
- 同一幂等业务重试时保留原 `Idempotency-Key` 和原正文;
|
||
- 每次 HTTP 尝试都重新生成 HMAC Timestamp、Nonce 和 Signature。
|
||
|
||
## 11. 代码与契约依据
|
||
|
||
- OpenAPI 契约:`openapi/openapi.yaml`
|
||
- 设备接口总文档:`docs/设备终端接口文档.md`
|
||
- 设备 HMAC 验签:`internal/deviceauth/verifier.go`
|
||
- 通话 HTTP 处理:`internal/httpapi/conversation_handlers.go`
|
||
- 通话业务编排:`internal/orchestration/service.go`
|
||
- 本地设备种子:`test/fixtures/local_seed.sql`
|
||
- 已验证参考实现(非规范):`device-simulator-windows/src/main.ts`
|
||
- 参考实现签名与请求代码:`device-simulator-windows/electron/main.cjs`
|
||
- 参考实现录音分片代码:`device-simulator-windows/src/recording.ts`
|
||
|
||
## 12. 联调检查清单
|
||
|
||
- [ ] 烧录/配置的设备 ID 为 `10000000-0000-0000-0000-000000000512`
|
||
- [ ] 设备安全区保存的是设备 `...0512` 对应密钥
|
||
- [ ] 设备 UTC 时钟与平台相差不超过允许窗口
|
||
- [ ] Bootstrap 返回 `registered` 和 `firmware_compatible=true`
|
||
- [ ] 设备已经绑定有效家庭和服务对象
|
||
- [ ] 服务对象具有有效 `voice_chat` 授权
|
||
- [ ] 火山 RTC 相关域名 TCP/TLS 443 可达
|
||
- [ ] RTC AppID 已开启实时字幕和流式语音识别
|
||
- [ ] 创建响应中的 Token 未被写入日志或磁盘
|
||
- [ ] RTC 入房成功并满足产品前置条件后才调用 Activate
|
||
- [ ] 心跳序号严格递增
|
||
- [ ] 最终字幕全部上传后才结束通话
|
||
- [ ] 只有 `privacy` 明确授权的轨道才采集和上传
|
||
- [ ] PUT 的实际 WAV 大小和 SHA-256 与声明一致
|
||
- [ ] 上传批次最终达到 `COMPLETED`
|
||
- [ ] 到 `local_delete_after` 后删除本地密文并回报删除结果
|