## 设备图:关键字映射 → 按名字取图
原来是一张写死的映射表(名字里含 echo-one/deepvoice/buds… 就用 earphone.png),
加一个产品就要回代码里加一条。改成直接按设备名找
`assets/images/devices/<设备名>.png` 与 `<设备名>_icon.png`,
加新产品只要按这个命名把图丢进目录——pubspec 里该目录是**按目录**声明的,
新文件自动打包,代码和配置都不用动。
四个展示点统一到 DeviceImages:设备页主图、设备管理列表小图标、
恒玄固件升级页、杰理固件升级页。其中:
- 设备管理小图标原来是 Material 图标 + 紫色渐变块,现在换成产品实拍图,
底色仍区分连接状态(换成图之后原来靠渐变色传达的「这台连着」就没了)。
⚠️ 这一页取图用的是**服务端下发的 devicename** 而不是当前蓝牙广播名——
它列的是绑定关系,可能包含此刻没连上的设备,用当前连接名会让所有行显示成同一台。
- 杰理固件升级页原来拉服务端 productimage 网络图,一并去掉:产品图统一走本地,
留着会出现「同一台设备在设备页显示本地图、在升级页显示另一张网络图」。
匹配三级,从严到宽:完全相同 → 忽略大小写 → 最长前缀(兜 `Echo-one_L`
这类带后缀的广播名)。第三级必须取**最长**匹配,否则目录里同时有 `Echo` 和
`Echo-one` 时前者会把后者的设备抢走。
启动时读一次 AssetManifest(main.dart)记下目录里实际有哪些图。不做这一步就只能
靠 Image.asset 的 errorBuilder 在渲染失败时兜底,那是**静默**的——美术漏出一张图
或命名对不上,界面直接变通用图,没有任何地方看得出来。现在会打 warn。
资源侧:earphone.png → Echo-one.png(同一张耳机实拍图,只是改名以适配新规则),
补上缺失的 default_icon.png,删掉已无引用的 earphone*/record_card*(省约 1.6MB)。
## 修同传/面对面页「一层阴影盖住标题和返回键」
不是阴影,是 Flutter 的错误控件。translation_view 的 AppBar 里有个 Obx 写成
`Platform.isIOS && controller.currentMode.value == ...`,安卓上 && 短路,
右半边根本不执行,这个 Obx 一个可观测变量都没订阅到,GetX 判「用法不当」抛错,
错误控件顶替了整条 AppBar(debug 红框、release 灰框)。iOS 上 Platform.isIOS
为真会继续求值,所以只有安卓复现。把可观测变量提到平台判断之前读即可。
全仓扫过,同样写法只此一处。
## 修 AI 页三张卡片 RIGHT OVERFLOWED BY 2.0 PIXELS
VIP 区容器有一圈 Border.all()(默认宽 1.0),Border 画在 padding 内侧、左右各占
1px,而列宽计算只扣了 padding 没扣 border,于是正好多出 2px。把 border 宽度提成
具名常量与计算共用,免得改一处又错位。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 846 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 430 KiB |
@ -0,0 +1,489 @@ |
|||
# BLE Recorder File Protocol Draft |
|||
|
|||
版本:V0.1 |
|||
|
|||
本文档定义 APP 通过 BLE 同步设备 TF 卡内容的私有协议草案,覆盖 TF 信息查询、文件列表、文件信息、文件传输、删除文件、格式化和录音状态上报。 |
|||
|
|||
## BLE GATT |
|||
|
|||
建议使用一个自定义 Service,两个 Characteristic。 |
|||
|
|||
| Characteristic | 方向 | 属性 | 用途 | |
|||
| --- | --- | --- | --- | |
|||
| CMD_RX | APP -> Device | Write 或 Write Without Response | APP 下发命令、传输配置、取消 | |
|||
| CMD_TX | Device -> APP | Notify | 设备返回响应、连续文件数据 | |
|||
|
|||
当前工程实现使用 16-bit UUID: |
|||
|
|||
| 项目 | UUID | |
|||
| --- | --- | |
|||
| Service | `0xFDA5` | |
|||
| CMD_TX | `0xFDA6` | |
|||
| CMD_RX | `0xFDA7` | |
|||
|
|||
文件传输使用 Notify,不使用 Indicate。Indicate 每包都需要 ATT 确认,吞吐会明显下降。 |
|||
|
|||
## 基础约定 |
|||
|
|||
- 所有多字节整数均为 little-endian。 |
|||
- 所有结构体均为 packed。 |
|||
- 每个 BLE value 均以 `ble_rec_pkt_hdr_t` 开头。 |
|||
- 请求和响应不再用 `type` 区分,由 BLE 方向和 `cmd` 决定。 |
|||
- 文件查询、传输、删除固定在 TF 卡 `\REC\` 目录下。 |
|||
- APP 只传文件名,不传路径,例如 `MIC0016.OGG`。 |
|||
- 第一版文件名固定使用 8.3 短文件名,建议大写 ASCII,例如 `MIC0016.OGG`。 |
|||
- 所有变长 `name[]` 均必须带字符串结束符 `\0`。 |
|||
- `name_len` 表示 `name[]` 总长度,包含结尾 `\0`。例如 `MIC0016.OGG` 的 `name_len = 12`。 |
|||
- 接收方应同时校验 `name_len` 和结尾 `\0`,两者任一异常都按参数错误处理。 |
|||
- `name_len` 最小值为 2,最后一个字节必须是 `0x00`,前面的文件名内容不能包含 `\` 或 `/`。 |
|||
- 协议版本不在线协商,APP 和固件按项目版本配套发布。后续新增字段尽量只在结构体末尾扩展。 |
|||
|
|||
结构体说明: |
|||
|
|||
- 文档中的 packed 结构体表示 BLE 线格式,不要求 APP 或固件直接对收包 buffer 做结构体指针强转。 |
|||
- 解析 `u16/u32` 时应按 little-endian 从字节流读取,避免非对齐访问问题。 |
|||
- `hdr.len` 表示 payload 长度,即 `BLE value 总长度 - sizeof(ble_rec_pkt_hdr_t)`。 |
|||
- 变长数组所在结构体的实际长度需要按字段长度计算,不能使用 `sizeof()` 直接得到完整包长。 |
|||
|
|||
示例宏: |
|||
|
|||
```c |
|||
#ifndef PACKED |
|||
#define PACKED __attribute__((packed)) |
|||
#endif |
|||
``` |
|||
|
|||
## 包头 |
|||
|
|||
包头保留 `magic/cmd/seq/len`,用于快速识别协议包、匹配请求响应、检查 payload 长度。 |
|||
|
|||
```c |
|||
#define BLE_REC_PKT_MAGIC 0xA5 |
|||
|
|||
typedef struct PACKED { |
|||
u8 magic; // BLE_REC_PKT_MAGIC |
|||
u8 cmd; // BLE_REC_CMD_xxx |
|||
u8 seq; // 发送方独立递增序号 |
|||
u16 len; // payload 长度,不包含本包头 |
|||
} ble_rec_pkt_hdr_t; |
|||
``` |
|||
|
|||
约定: |
|||
|
|||
- APP -> Device 和 Device -> APP 两个方向的 `seq` 独立递增。 |
|||
- `seq` 为 u8,递增到 0xFF 后回绕到 0x00。 |
|||
- 设备响应不回填 APP 请求的 `seq`,而是使用设备侧自己的响应 `seq`。 |
|||
- 第一版协议按单事务处理:APP 发出请求后,等待对应 `cmd` 的设备响应,再发下一个普通请求。 |
|||
- 录音状态变化时的 `REC_INFO` 和录音停止后紧随的 `FILE_LIST` 是设备主动 Notify,不对应 APP 请求。 |
|||
- 文件传输 DATA 使用设备侧连续递增 `seq`,数据顺序和缺口主要由 `file_ofs` 判断。 |
|||
- `len` 必须等于 BLE value 长度减 `sizeof(ble_rec_pkt_hdr_t)`。 |
|||
- 收到 `magic` 不匹配或 `len` 不匹配的包,直接丢弃。 |
|||
- 普通命令建议 APP 使用 3 秒超时;超时后可重新发送当前命令。 |
|||
|
|||
## MTU 和数据长度 |
|||
|
|||
BLE ATT Notify/Write 的应用 value 最大长度通常为: |
|||
|
|||
```text |
|||
ble_value_max = att_mtu - 3 |
|||
``` |
|||
|
|||
协议本身不再协商 `max_payload`。设备端从 BLE 栈获取当前 MTU 后自行计算单包最大 data 长度。 |
|||
|
|||
文件 DATA 包最大数据长度: |
|||
|
|||
```text |
|||
data_max = att_mtu - 3 - sizeof(ble_rec_pkt_hdr_t) - sizeof(action_cmd) |
|||
- sizeof(file_ofs) - sizeof(control) - sizeof(sum) |
|||
|
|||
data_max = att_mtu - 16 |
|||
``` |
|||
|
|||
例:`att_mtu = 247` 时,`data_max = 231`。 |
|||
|
|||
设备实现时可再取 `min(data_max, 512)`,避免文件系统读缓存过大。 |
|||
|
|||
## 命令总览 |
|||
|
|||
| Cmd | 名称 | 说明 | 第一版建议 | |
|||
| ---: | --- | --- | --- | |
|||
| 0x01 | REC_INFO | 查询 TF 信息和录音状态;录音状态变化时设备主动上报 | 必做 | |
|||
| 0x02 | FILE_LIST | 获取完整文件列表 | 必做 | |
|||
| 0x03 | FILE_INFO | 查询指定文件信息 | 可选 | |
|||
| 0x04 | FILE_TRANSFER | 文件传输,设备连续 Notify | 必做 | |
|||
| 0x05 | FILE_DELETE | 删除文件 | 第二步 | |
|||
| 0x06 | REC_FORMAT | 格式化 TF 卡 | 第二步 | |
|||
| 0x07 | RTC_SYNC | APP 同步设备 RTC 时间 | 必做 | |
|||
|
|||
说明: |
|||
|
|||
- `FILE_INFO` 不是首版必需,因为 `FILE_LIST` 已返回文件大小和时间;保留它是为了 APP 删除/下载前单独刷新文件状态。 |
|||
- 删除和格式化属于危险操作,建议等列表和传输稳定后再接。 |
|||
|
|||
## 状态码 |
|||
|
|||
响应结构体中的 `status` 使用以下取值。 |
|||
|
|||
```c |
|||
enum { |
|||
BLE_REC_ST_OK = 0x00, |
|||
BLE_REC_ST_PARAM_ERR = 0x01, |
|||
BLE_REC_ST_BUSY = 0x02, |
|||
BLE_REC_ST_NO_CARD = 0x03, |
|||
BLE_REC_ST_FS_ERR = 0x04, |
|||
BLE_REC_ST_FILE_NOT_FOUND = 0x05, |
|||
BLE_REC_ST_ACCESS_DENIED = 0x06, |
|||
BLE_REC_ST_UNSUPPORTED = 0x07, |
|||
BLE_REC_ST_NO_SPACE = 0x08, |
|||
BLE_REC_ST_CANCELLED = 0x09, |
|||
}; |
|||
``` |
|||
|
|||
## 0x01 REC_INFO |
|||
|
|||
查询 TF 卡信息和录音状态。容量单位为 KB;录音开始、停止时,设备也使用此响应结构主动 Notify。 |
|||
|
|||
```c |
|||
#define BLE_REC_CMD_INFO 0x01 |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_INFO, len = 0 |
|||
} ble_rec_info_req_t; |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_INFO |
|||
u8 status; // BLE_REC_ST_xxx |
|||
u8 fs_type; // 1:FAT12 / 2:FAT16 / 3:FAT32 / 4:exFAT |
|||
u32 total_kb; // TF 总容量,单位 KB |
|||
u32 used_kb; // 已使用容量,单位 KB |
|||
u8 rec_state; // 0:已停止 / 1:录音中 |
|||
} ble_rec_info_rsp_t; |
|||
``` |
|||
|
|||
`u32` KB 单位最大可表示约 4TB,足够覆盖当前 128MB TF 卡和后续常见容量。 |
|||
|
|||
`used_kb` 建议按 `total_kb - free_kb` 计算。如果当前文件系统接口暂时无法快速获取剩余空间,可以后续补实现,但协议字段保持不变。 |
|||
|
|||
`rec_state` 始终有效。如果 `status != BLE_REC_ST_OK`,APP 仍可更新录音状态,其余信息字段按 0 处理。 |
|||
|
|||
录音状态变化时,设备主动发送 `REC_INFO`,`status = BLE_REC_ST_OK`。其中容量信息取最近一次成功查询的结果,APP 应以 `rec_state` 为主要关注字段。录音停止状态发送成功后,设备主动发送完整 `FILE_LIST`,用于刷新新生成的录音文件。 |
|||
|
|||
## 0x02 FILE_LIST |
|||
|
|||
获取 `\REC\` 目录下的完整文件列表。APP 只发一次请求,设备固定返回所有文件条目;如果一包放不下,设备连续 Notify 多个 FILE_LIST 响应包,直到 `eof = 1`。 |
|||
|
|||
文件顺序由 FAT 目录项扫描顺序决定,不保证按文件名或时间排序。APP 如需排序,建议本地排序。 |
|||
|
|||
```c |
|||
#define BLE_REC_CMD_FILE_LIST 0x02 |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_LIST, len = 0 |
|||
} ble_rec_file_list_req_t; |
|||
|
|||
typedef struct PACKED { |
|||
u32 size; // 文件大小 |
|||
u32 ftime; // FAT 时间,格式同 fs_get_ftime() |
|||
u8 name_len; // name[] 总长度,包含结尾 '\0' |
|||
u8 name[]; // 文件名,变长,以 '\0' 结尾 |
|||
} ble_rec_file_item_t; |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_LIST |
|||
u8 status; // BLE_REC_ST_xxx |
|||
u8 item_count; // 本包 item 数量 |
|||
u8 eof; // 1: 所有文件列表返回结束 |
|||
u8 items[]; // ble_rec_file_item_t 数组,逐项变长解析 |
|||
} ble_rec_file_list_rsp_t; |
|||
``` |
|||
|
|||
建议: |
|||
|
|||
- 默认固定扫描 `\REC\` 目录,过滤隐藏/系统文件。 |
|||
- 如果 `\REC\` 目录不存在,返回 `status = BLE_REC_ST_OK`、`item_count = 0`、`eof = 1`。 |
|||
- 如果 TF 卡不存在,返回 `status = BLE_REC_ST_NO_CARD`、`item_count = 0`、`eof = 1`。 |
|||
- 设备每个响应包尽量填满当前 MTU 可容纳的 item。 |
|||
- 单个 `ble_rec_file_item_t` 不跨 BLE 包;如果当前剩余空间放不下完整 item,则放到下一个 FILE_LIST 响应包。 |
|||
- 每个 item 的实际长度为 `sizeof(size) + sizeof(ftime) + sizeof(name_len) + name_len`,其中 `name_len` 包含结尾 `\0`。 |
|||
- APP 解析时应同时校验 `hdr.len` 和 `item_count`,避免异常包导致越界解析。 |
|||
- 如果文件列表需要多包返回,除最后一包外 `eof = 0`,最后一包 `eof = 1`。 |
|||
- 删除或新增文件后,APP 重新发送一次 FILE_LIST 获取完整列表。 |
|||
|
|||
## 0x03 FILE_INFO |
|||
|
|||
查询指定文件信息。该命令可选,首版可以先不实现。 |
|||
|
|||
```c |
|||
#define BLE_REC_CMD_FILE_INFO 0x03 |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_INFO |
|||
u8 name_len; // name[] 总长度,包含结尾 '\0' |
|||
u8 name[]; // \REC\ 目录下的文件名,以 '\0' 结尾 |
|||
} ble_rec_file_info_req_t; |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_INFO |
|||
u8 status; // BLE_REC_ST_xxx |
|||
u32 size; // 文件大小 |
|||
u32 ftime; // FAT 时间 |
|||
} ble_rec_file_info_rsp_t; |
|||
``` |
|||
|
|||
如果 `status != BLE_REC_ST_OK`,APP 只处理 `status` 字段,其余字段按 0 处理。 |
|||
|
|||
## 0x04 FILE_TRANSFER |
|||
|
|||
文件传输使用一个命令,通过 `action_cmd` 区分配置、数据、取消。 |
|||
|
|||
核心策略: |
|||
|
|||
- APP 发一次 SETUP。 |
|||
- 设备返回 SETUP_RSP 后连续 Notify DATA。 |
|||
- APP 不逐包请求数据。 |
|||
- 设备同一时间只允许一个文件传输,DATA/CANCEL 默认作用于当前传输。 |
|||
- 如果 APP 发现 offset 缺口,发送 CANCEL,然后重新 SETUP,从期望 offset 继续。 |
|||
|
|||
### 结构体 |
|||
|
|||
```c |
|||
#define BLE_REC_CMD_FILE_TRANSFER 0x04 |
|||
|
|||
enum { |
|||
BLE_REC_TRANS_SETUP = 0x01, // APP -> Device,设置传输配置 |
|||
BLE_REC_TRANS_SETUP_RSP = 0x02, // Device -> APP,配置响应 |
|||
BLE_REC_TRANS_DATA = 0x03, // Device -> APP,文件数据 |
|||
BLE_REC_TRANS_CANCEL = 0x04, // 双向,取消/中断传输 |
|||
}; |
|||
|
|||
enum { |
|||
BLE_REC_DATA_CONTINUE = 0x00, // 继续 |
|||
BLE_REC_DATA_END = 0x01, // 本包后传输结束 |
|||
}; |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_TRANSFER |
|||
u8 action_cmd; // BLE_REC_TRANS_xxx |
|||
union { |
|||
/* BLE_REC_TRANS_SETUP: APP -> Device */ |
|||
struct PACKED { |
|||
u32 start_ofs; // 起始偏移,断点续传时使用 |
|||
u8 name_len; // name[] 总长度,包含结尾 '\0' |
|||
u8 name[]; // \REC\ 目录下的文件名,以 '\0' 结尾 |
|||
} setup; |
|||
|
|||
/* BLE_REC_TRANS_SETUP_RSP: Device -> APP */ |
|||
struct PACKED { |
|||
u8 status; // BLE_REC_ST_xxx |
|||
u32 file_size; // 文件总大小 |
|||
u32 start_ofs; // 实际起始偏移 |
|||
u32 ftime; // FAT 时间 |
|||
u16 data_len; // 设备实际采用的单包 data 长度 |
|||
} setup_rsp; |
|||
|
|||
/* BLE_REC_TRANS_DATA: Device -> APP */ |
|||
struct PACKED { |
|||
u32 file_ofs; // 本包 data 在文件中的偏移 |
|||
u8 control; // BLE_REC_DATA_CONTINUE / BLE_REC_DATA_END |
|||
u16 sum; // 本包校验和,计算时 sum 字段按 0 处理 |
|||
u8 data[]; // 文件数据 |
|||
} data; |
|||
|
|||
/* BLE_REC_TRANS_CANCEL: 双向 */ |
|||
struct PACKED { |
|||
u8 reason; // 0:用户取消 / 1:offset异常 / 2:超时 / 3:设备错误 |
|||
} cancel; |
|||
}; |
|||
} ble_rec_file_transfer_t; |
|||
``` |
|||
|
|||
SETUP_RSP 说明: |
|||
|
|||
- 如果 `status != BLE_REC_ST_OK`,APP 只处理 `status` 字段,其余字段按 0 处理。 |
|||
- 如果 `status == BLE_REC_ST_OK`,APP 保存 `file_size/start_ofs/data_len`,后续 DATA/CANCEL 都默认属于当前传输。 |
|||
- 当前传输结束、取消、超时或 BLE 断开后,设备清空当前传输状态;断点续传需要重新 SETUP。 |
|||
|
|||
DATA 实际数据长度: |
|||
|
|||
```text |
|||
data_actual_len = hdr.len - sizeof(action_cmd) |
|||
- sizeof(file_ofs) |
|||
- sizeof(control) |
|||
- sizeof(sum) |
|||
|
|||
data_actual_len = hdr.len - 8 |
|||
``` |
|||
|
|||
APP 必须使用 `data_actual_len` 解析 `data[]`,不要使用 SETUP_RSP 里的 `data_len` 作为每包固定长度。最后一包通常会小于 `data_len`。 |
|||
|
|||
### DATA 校验和 |
|||
|
|||
`sum` 是一个轻量 sanity check,不用于替代 BLE 链路层 CRC。 |
|||
|
|||
建议算法: |
|||
|
|||
```c |
|||
u16 sum = 0; |
|||
for (i = 0; i < packet_len; i++) { |
|||
sum += packet[i]; |
|||
} |
|||
``` |
|||
|
|||
校验范围为整个 BLE value,即从 `hdr.magic` 到 `data[]` 最后一个字节。计算时 `sum` 字段两个字节按 0 处理。APP 收到 DATA 后按同样方式计算,结果一致才写入本地文件。 |
|||
|
|||
如果实测 CPU 开销影响吞吐,可以后续约定 `sum = 0` 表示不启用 DATA 校验和;第一版建议先保留,方便调试。 |
|||
|
|||
### 设备发送策略 |
|||
|
|||
- 如果 `\REC\` 目录不存在或目标文件不存在,SETUP_RSP 返回 `BLE_REC_ST_FILE_NOT_FOUND`。 |
|||
- SETUP 成功后,设备尽快连续发送 DATA。 |
|||
- 能发就发,直到 BLE TX 队列满、文件结束、收到 CANCEL 或文件系统错误。 |
|||
- 如果传输过程中发生文件系统错误,设备发送 `BLE_REC_TRANS_CANCEL`,`reason = 3`。 |
|||
- BLE TX 队列满时不要忙等,等 notify complete/tx credit 回调后继续发送。 |
|||
- 每次从 TF 读取的长度不超过 `data_len`,复用一个静态缓冲。 |
|||
- 长文件传输循环中定期喂狗。 |
|||
- 最后一包 DATA 的 `control = BLE_REC_DATA_END`。如果文件大小刚好按 data_len 对齐,可以发送一个 0 字节 END 包。 |
|||
|
|||
### APP 接收策略 |
|||
|
|||
- 按 `file_ofs` 写入本地文件。 |
|||
- 如果收到重复 DATA,可按 offset 去重。 |
|||
- 如果 `file_ofs` 不等于期望 offset,发送 CANCEL,然后重新 SETUP,从期望 offset 继续。 |
|||
- 如果 APP 超过 5 秒没有收到新的 DATA 或 CANCEL,可认为传输超时,重新 SETUP 从已保存 offset 续传。 |
|||
|
|||
## 0x05 FILE_DELETE |
|||
|
|||
删除指定文件。 |
|||
|
|||
```c |
|||
#define BLE_REC_CMD_FILE_DELETE 0x05 |
|||
#define BLE_REC_DELETE_CONFIRM 0x44454C21 // "DEL!" |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_DELETE |
|||
u32 confirm; // BLE_REC_DELETE_CONFIRM |
|||
u8 name_len; // name[] 总长度,包含结尾 '\0' |
|||
u8 name[]; // \REC\ 目录下的文件名,以 '\0' 结尾 |
|||
} ble_rec_file_delete_req_t; |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_DELETE |
|||
u8 status; // BLE_REC_ST_xxx |
|||
} ble_rec_file_delete_rsp_t; |
|||
``` |
|||
|
|||
安全要求: |
|||
|
|||
- 录音中返回 `BLE_REC_ST_BUSY`。 |
|||
- 文件传输中返回 `BLE_REC_ST_BUSY`,或要求 APP 先 CANCEL。 |
|||
- 如果 `\REC\` 目录不存在或目标文件不存在,返回 `BLE_REC_ST_FILE_NOT_FOUND`。 |
|||
- APP UI 侧必须二次确认。 |
|||
|
|||
## 0x06 REC_FORMAT |
|||
|
|||
格式化 TF 卡。 |
|||
|
|||
```c |
|||
#define BLE_REC_CMD_FORMAT 0x06 |
|||
#define BLE_REC_FORMAT_CONFIRM 0x464D5421 // "FMT!" |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FORMAT |
|||
u32 confirm; // BLE_REC_FORMAT_CONFIRM |
|||
u8 fs_type; // 0: 自动 |
|||
} ble_rec_format_req_t; |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FORMAT |
|||
u8 status; // BLE_REC_ST_xxx |
|||
} ble_rec_format_rsp_t; |
|||
``` |
|||
|
|||
安全要求: |
|||
|
|||
- 录音中返回 `BLE_REC_ST_BUSY`。 |
|||
- 文件传输中返回 `BLE_REC_ST_BUSY`。 |
|||
- 低电量返回 `BLE_REC_ST_ACCESS_DENIED`。 |
|||
- TF 卡不存在返回 `BLE_REC_ST_NO_CARD`。 |
|||
- APP UI 侧必须二次确认。 |
|||
|
|||
## 0x07 RTC_SYNC |
|||
|
|||
APP 将本地时间同步到设备 RTC。设备后续创建录音文件时,使用 RTC 时间生成 FAT 创建时间。 |
|||
|
|||
```c |
|||
#define BLE_REC_CMD_RTC_SYNC 0x07 |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_RTC_SYNC |
|||
u16 year; // 1980~2106 |
|||
u8 month; // 1~12 |
|||
u8 day; // 1~31,设备按年月校验 |
|||
u8 hour; // 0~23 |
|||
u8 minute; // 0~59 |
|||
u8 second; // 0~59 |
|||
} ble_rec_rtc_sync_req_t; |
|||
|
|||
typedef struct PACKED { |
|||
ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_RTC_SYNC |
|||
u8 status; // BLE_REC_ST_xxx |
|||
} ble_rec_rtc_sync_rsp_t; |
|||
``` |
|||
|
|||
说明: |
|||
- 时间按设备本地时间理解,不做时区字段协商。 |
|||
- 建议 APP 在 BLE 连接成功后主动同步一次 RTC,再查询/下载文件。 |
|||
- 如果设备 RTC 未同步或时间无效,录音文件创建时间会回退到旧的递增时间策略,避免写入非法 FAT 时间。 |
|||
|
|||
## 典型流程 |
|||
|
|||
### 枚举文件 |
|||
|
|||
1. APP 使能 CMD_TX Notify。 |
|||
2. APP -> `RTC_SYNC`。 |
|||
3. APP -> `REC_INFO`。 |
|||
4. APP -> `FILE_LIST`。 |
|||
5. 设备连续返回 FILE_LIST 响应包,APP 收集 item,直到 `eof = 1`。 |
|||
|
|||
### 下载文件,最高速度模式 |
|||
|
|||
1. APP -> `FILE_TRANSFER / SETUP`,`start_ofs = 0`。 |
|||
2. 设备 -> `FILE_TRANSFER / SETUP_RSP`。 |
|||
3. 设备连续 Notify `FILE_TRANSFER / DATA`。 |
|||
4. APP 按 offset 写文件。 |
|||
5. APP 收到 `control = BLE_REC_DATA_END` 后,确认本地文件大小等于 `file_size`。 |
|||
|
|||
### 断点续传 |
|||
|
|||
1. APP 本地已有 `saved_offset`。 |
|||
2. APP -> `FILE_TRANSFER / SETUP`,`start_ofs = saved_offset`。 |
|||
3. 设备从该 offset 开始连续发送 DATA。 |
|||
|
|||
### 删除文件 |
|||
|
|||
1. APP UI 二次确认。 |
|||
2. APP -> `FILE_DELETE(confirm = "DEL!")`。 |
|||
3. 设备返回 OK 后,APP 重新 `FILE_LIST`。 |
|||
|
|||
### 格式化 |
|||
|
|||
1. APP UI 二次确认。 |
|||
2. APP -> `REC_FORMAT(confirm = "FMT!")`。 |
|||
3. 成功后 APP 重新 `REC_INFO` 和 `FILE_LIST`。 |
|||
|
|||
## 第一版实现建议 |
|||
|
|||
第一版建议只实现: |
|||
|
|||
- `REC_INFO` |
|||
- `RTC_SYNC` |
|||
- `FILE_LIST` |
|||
- `FILE_TRANSFER` |
|||
|
|||
之后再补: |
|||
|
|||
- `FILE_INFO` |
|||
- `FILE_DELETE` |
|||
- `REC_FORMAT` |
|||
|
|||
这样可以先把 APP 浏览和高速下载主链路跑通,再加危险操作。 |
|||
@ -0,0 +1,112 @@ |
|||
import 'package:flutter/services.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
|
|||
/// 按设备名解析产品图与产品小图标。 |
|||
/// |
|||
/// 约定:`assets/images/devices/<设备名>.png` 是主图, |
|||
/// `assets/images/devices/<设备名>_icon.png` 是同一产品的小图标。 |
|||
/// 加新产品只要把两张图按这个命名丢进目录即可 —— pubspec 里 |
|||
/// `assets/images/devices/` 是**按目录**声明的,新文件会自动打包,不用改配置。 |
|||
/// |
|||
/// ⚠️ 图片是打进安装包的,所以**新产品必须发版才能看到**。 |
|||
/// 服务端 `DBProduct.productimage` 那条网络图的路子(后台传图、老包也能看到) |
|||
/// 是刻意没走的,见 2026-09-08 的决定。 |
|||
class DeviceImages { |
|||
DeviceImages._(); |
|||
|
|||
static const String _tag = 'DeviceImages'; |
|||
static const String dir = 'assets/images/devices/'; |
|||
static const String defaultImage = '${dir}default.png'; |
|||
static const String defaultIcon = '${dir}default_icon.png'; |
|||
|
|||
static const String _iconSuffix = '_icon'; |
|||
|
|||
/// 目录下所有产品主图的名字(不含 `.png`,不含 `_icon` 那些) |
|||
static final Set<String> _mainNames = <String>{}; |
|||
|
|||
/// 目录下实际存在的全部条目(含 `_icon`),用来判断小图标在不在 |
|||
static final Set<String> _allNames = <String>{}; |
|||
|
|||
static bool _loaded = false; |
|||
|
|||
/// 读一次 AssetManifest,把目录里实际有哪些图记下来。 |
|||
/// |
|||
/// 为什么要这一步:Flutter 没法同步判断某个 asset 存不存在。不预先知道 |
|||
/// 有哪些图,就只能靠 `Image.asset` 的 errorBuilder 在渲染失败时兜底—— |
|||
/// 那是**静默**的,美术漏出一张图或者命名对不上,界面直接变成通用图, |
|||
/// 没有任何地方能看出来。有了清单就能在解析时打一条 warn。 |
|||
static Future<void> load() async { |
|||
try { |
|||
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); |
|||
for (final key in manifest.listAssets()) { |
|||
if (!key.startsWith(dir) || !key.endsWith('.png')) continue; |
|||
final base = key.substring(dir.length, key.length - 4); |
|||
if (base.isEmpty) continue; |
|||
_allNames.add(base); |
|||
if (!base.endsWith(_iconSuffix)) _mainNames.add(base); |
|||
} |
|||
_loaded = true; |
|||
Logger.i(_tag, '产品图清单已载入:${_mainNames.length} 个产品 ${_allNames.length} 个文件'); |
|||
} catch (e) { |
|||
Logger.w(_tag, '读取产品图清单失败,全部退回默认图: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 设备主图。匹配不上返回 [defaultImage]。 |
|||
static String imageFor(String? deviceName) { |
|||
final match = _match(deviceName); |
|||
return match == null ? defaultImage : '$dir$match.png'; |
|||
} |
|||
|
|||
/// 设备小图标。 |
|||
/// |
|||
/// 顺序:`<名字>_icon.png` → `<名字>.png` → `default_icon.png`。 |
|||
/// 中间那一档是刻意留的:产品有主图但美术没出小图时,用主图缩着显示 |
|||
/// 也比掉成通用图强 —— 掉通用图会让用户以为「App 不认识我的设备」。 |
|||
/// 走到这一档会打 warn,提醒把 `_icon` 补上。 |
|||
static String iconFor(String? deviceName) { |
|||
final match = _match(deviceName); |
|||
if (match == null) return defaultIcon; |
|||
final icon = '$match$_iconSuffix'; |
|||
if (_allNames.contains(icon)) return '$dir$icon.png'; |
|||
Logger.w(_tag, '产品 $match 缺小图标 $icon.png,暂用主图顶替'); |
|||
return '$dir$match.png'; |
|||
} |
|||
|
|||
/// 把设备名匹配到目录里的某个产品名;匹配不上返回 null。 |
|||
/// |
|||
/// 三级,从严到宽: |
|||
/// 1. 完全相同(`Echo-one` → `Echo-one.png`) |
|||
/// 2. 忽略大小写(防美术出图时大小写和固件广播名不一致) |
|||
/// 3. 最长前缀(`Echo-one_L`、`Echo-one-3F2A` 这类带后缀的广播名) |
|||
/// |
|||
/// ⚠️ 第 3 级必须取**最长**匹配。按任意顺序取第一个命中的话, |
|||
/// 目录里若同时有 `Echo` 和 `Echo-one`,`Echo-one` 的设备可能被 `Echo` 抢走。 |
|||
static String? _match(String? deviceName) { |
|||
final name = (deviceName ?? '').trim(); |
|||
if (name.isEmpty) return null; |
|||
if (!_loaded) { |
|||
// 清单还没载入(理论上不会发生,load() 在 runApp 之前 await) |
|||
Logger.w(_tag, '产品图清单未载入就来取图,先退默认:$name'); |
|||
return null; |
|||
} |
|||
|
|||
if (_mainNames.contains(name)) return name; |
|||
|
|||
final lower = name.toLowerCase(); |
|||
for (final n in _mainNames) { |
|||
if (n.toLowerCase() == lower) return n; |
|||
} |
|||
|
|||
String? best; |
|||
for (final n in _mainNames) { |
|||
if (n.isEmpty || !lower.startsWith(n.toLowerCase())) continue; |
|||
if (best == null || n.length > best.length) best = n; |
|||
} |
|||
if (best == null) { |
|||
Logger.w(_tag, '没有 $name 对应的产品图,退回默认图'); |
|||
} |
|||
return best; |
|||
} |
|||
} |
|||
@ -0,0 +1,687 @@ |
|||
# EAI 录音支架 · BLE 协议规范 |
|||
|
|||
版本:**v0.1(草案,待固件确认)** |
|||
日期:2026-09-08 |
|||
适用设备:**带 TF 卡存储的 EAI 录音支架(唯一形态)** |
|||
|
|||
--- |
|||
|
|||
## 0. 本文档的定位 |
|||
|
|||
本文档是新支架的**唯一协议依据**,由两部分合并而来: |
|||
|
|||
| 来源 | 内容 | 可信度 | |
|||
|---|---|---| |
|||
| 现有支架的代码实现 | 控制命令、实时 Opus 音频流、连接/重连/MTU 策略 | **已上线跑通**,以代码为准(工程此前无协议文档,代码即规范) | |
|||
| [recorder_protocol(2)(1).md](<recorder_protocol(2)(1).md>) | TF 卡文件管理(列表/传输/删除/格式化/RTC) | **纯草案**,仓库里躺着但一行未实现,未经真机验证 | |
|||
|
|||
**明确不做旧设备兼容。** 项目只有这一款带存储的支架,旧的无存储支架不再支持。 |
|||
文中标 `[沿用]` 的条目表示与现有代码一致、固件无需改动;标 `[新增]` 的是本次新加; |
|||
标 `[待确认]` 的是本文提出、需固件方在 §11 逐条回复的。 |
|||
|
|||
--- |
|||
|
|||
## 1. 链路总览 |
|||
|
|||
设备对 App 暴露 **三条逻辑通道**,跑在**同一条 GATT 连接**上,靠不同的 Service/Characteristic 隔离: |
|||
|
|||
| 通道 | 用途 | Service | 写(App→设备) | 通知(设备→App) | |
|||
|---|---|---|---|---| |
|||
| **A. 控制** `[沿用]` | 设备信息、电量、编解码控制、按键事件 | `0000abc0-0000-1111-2222-123456789abc` | `0000abc1-…`(Write No Response) | `0000abc2-…`(Notify) | |
|||
| **A'. 实时音频上行** `[沿用]` | 设备麦克风 Opus 流 | `0000ae00-0000-1000-8000-00805f9b34fb` | — | `0000ae02-…`(Notify) | |
|||
| **A''. 实时音频下行** `[沿用]` | App→设备播放音频 | `0000abc0-0001-1111-2222-123456789abc` | `0000abc1-0001-…`(Write) | — | |
|||
| **B. TF 卡文件** `[新增]` | 文件列表、下载、删除、格式化、RTC | `0xFDA5` | `0xFDA7`(Write / Write No Response) | `0xFDA6`(Notify) | |
|||
| **C. OTA** `[待定]` | 固件升级 | 见 §9,本版留占位 | | | |
|||
|
|||
另有标准服务 `0x180A`(Device Information Service),用于**固件代差识别**,见 §4.6。 |
|||
|
|||
> **两套帧格式完全不同,绝不能混用。** 通道 A 是 `AA/BB/CC` 帧头 + CRC8; |
|||
> 通道 B 是 `0xA5` magic + seq + u16 长度。两者的命令号 `0x01~0x07` **数值重叠但语义完全不同** |
|||
> (A 的 `0x04` 是电量,B 的 `0x04` 是文件传输)——这是可以接受的,因为它们在不同的 characteristic 上, |
|||
> 解析入口按 characteristic UUID 分派即可。**但 App 侧不能复用同一个解析函数。** |
|||
|
|||
--- |
|||
|
|||
## 2. 连接、扫描与设备校验 |
|||
|
|||
本章是**项目框架既有规则,不随设备更换而改变**。 |
|||
|
|||
### 2.1 扫描 |
|||
|
|||
- App 侧**不做 Service UUID 过滤**,全量扫描后按**设备名前缀**筛选(现有 SDK `startScan(nameFilters:)` 的做法)。 |
|||
设备广播什么 UUID 由固件决定,App 跟随即可,不作为筛选条件。 |
|||
- 广播原始字节(`scanRecord.bytes` 的 hex)会被 App 记录并透传给服务端绑定接口,见 §2.3。 |
|||
- `[待确认 Q1]` 新支架的广播设备名前缀是什么?改名会直接导致扫描页扫不到。 |
|||
|
|||
### 2.2 连接 |
|||
|
|||
`[沿用]` 现有连接与保活策略全部保留,固件侧无需配合改动: |
|||
|
|||
| 项 | 值 | |
|||
|---|---| |
|||
| 写特征写类型 | `WRITE_TYPE_NO_RESPONSE`(通道 A 控制) | |
|||
| MTU 请求 | 连接后 `requestMtu(512)`,取双方 min,结果由 `onMtuChanged` 回传 | |
|||
| **MTU 请求时机** | **必须在 CCCD 写完后延时 1s 再发**。vivo 等魔改 ROM 在 CCCD `writeDescriptor` 还在队列里时紧跟着发 `requestMtu` 会被协议栈**静默吞掉**(`accepted=true` 但 `ATT_EXCHANGE_MTU_REQ` 根本没上空中),空中抓不到协商 → 数据模式起不来 → 录音失败。同时 MCU 在 connection complete 后也需要几百 ms 进入待命态。 | |
|||
| 通知使能顺序 | **先使能音频特征通知,再使能控制特征通知**,顺序颠倒会收不到音频 | |
|||
| 自动重连 | 最多 10 次,退避 `2s/3s/5s/5s/10s`;主动断开不重连 | |
|||
| 后台唤醒 | Android 走 CompanionDeviceManager + 前台服务;iOS 走 CoreBluetooth 后台中央模式 | |
|||
|
|||
### 2.3 设备校验与绑定 —— **按 MAC,不校验 license** |
|||
|
|||
这是项目框架的设备确权规则,**新支架照旧,不引入任何设备端授权码/license 字段**。 |
|||
|
|||
链路:连接前先调服务端 `user_binddevice` 登记,参数: |
|||
|
|||
``` |
|||
pid = 广播原始 hex(服务端已不采信,仅留作诊断) |
|||
code = "" ← 空。不走授权码校验 |
|||
devicename = 广播名 |
|||
devicemac = 设备 MAC ← 唯一校验依据 |
|||
``` |
|||
|
|||
服务端 [api_binddevice.go](../../../services/modules/user/api_binddevice.go) 的定位逻辑: |
|||
先试 `code`(空则跳过)→ 再按 `devicemac` 在**全局 `device_mac` 表**里查 → 命中则从该行的 |
|||
`productid` 列读出产品。所以: |
|||
|
|||
- **设备的 MAC 必须事先由后台导入 `device_mac` 表**,否则返回 `AuthorizeNoCanUse`「设备未登记」。 |
|||
绑不上先去后台查 MAC 在不在,别在客户端找原因。 |
|||
- **固件侧无需为校验做任何事**——不需要在协议里提供授权码、序列号、license 字段。 |
|||
设备的身份就是它的蓝牙 MAC。 |
|||
- 绑定失败**不阻断连接和录音**,只是设备管理页没有记录(现有行为,保留)。 |
|||
|
|||
> ⚠️ **iOS 拿不到真 MAC**:CoreBluetooth 只给 peripheral UUID,每台手机各不相同, |
|||
> 不可能命中后台登记的 MAC。现状是 iOS 侧绑定登记必然失败(不阻断使用)。 |
|||
> 若要在 iOS 上真正做到 MAC 校验,只能让**设备通过协议把自己的 MAC 报上来**—— |
|||
> 见 `[待确认 Q2]`:建议在通道 A 增加一条「读取设备蓝牙地址」命令,或在 `REC_INFO` 响应里带上 MAC。 |
|||
|
|||
--- |
|||
|
|||
## 3. 通道 A:帧格式 `[沿用]` |
|||
|
|||
### 3.1 帧结构 |
|||
|
|||
三种帧头,**方向 + 语义**一起编码在第一个字节: |
|||
|
|||
``` |
|||
App → 设备(请求) : AA | cmd(1) | len(1) | data[len] | crc8(1) |
|||
设备 → App(响应) : BB | cmd(1) | len(1) | data[len] | crc8(1) |
|||
设备 → App(主动上报): CC | type(1) | len(1) | data[len] | crc8(1) |
|||
``` |
|||
|
|||
- 总帧长 = `4 + len`。`len` 是 u8,单帧 data 最大 255 字节。 |
|||
- 一条命令一个 BLE value,**不分片、不跨包**。 |
|||
- 收到帧长与 `len` 不符、或 CRC 不符的帧,**直接丢弃**(不回错误帧)。 |
|||
- **命令失败的表示**:设备回 `BB <cmd> 00 <crc>`,即 `len = 0` 表示该命令执行失败。 |
|||
这意味着**「成功但无返回数据」的命令无法表达**——所有成功响应必须至少带 1 字节 data。 |
|||
`[待确认 Q3]` 这条隐含约束是否为固件方本意?建议改成显式状态字节。 |
|||
|
|||
### 3.2 CRC8 |
|||
|
|||
覆盖范围:**从帧头字节到 data 最后一个字节**(不含 CRC 自身)。 |
|||
|
|||
``` |
|||
poly = 0x31 |
|||
init = 0x00 |
|||
refin = false (MSB-first,逐位左移) |
|||
refout = false |
|||
xorout = 0x00 |
|||
``` |
|||
|
|||
```c |
|||
u8 crc8(const u8 *d, int n) { |
|||
u8 crc = 0; |
|||
for (int i = 0; i < n; i++) { |
|||
crc ^= d[i]; |
|||
for (int j = 0; j < 8; j++) |
|||
crc = (crc & 0x80) ? ((crc << 1) ^ 0x31) : (crc << 1); |
|||
} |
|||
return crc; |
|||
} |
|||
``` |
|||
|
|||
> ⚠️ **现有代码里这个函数名叫 `crc8Maxim`,但它不是 CRC-8/MAXIM。** |
|||
> 标准 CRC-8/MAXIM 是反射式(等效多项式 0x8C,refin/refout = true), |
|||
> 与上面的实现结果**不同**。固件方请**照上面的代码实现,不要照名字去查表**—— |
|||
> 查错表会导致所有帧被判 CRC 失败并静默丢弃,表现是「命令发出去石沉大海」。 |
|||
|
|||
--- |
|||
|
|||
## 4. 通道 A:命令集 `[沿用]` |
|||
|
|||
### 4.1 命令总览 |
|||
|
|||
| cmd | 名称 | 方向 | 请求 data | 响应 data | |
|||
|---:|---|---|---|---| |
|||
| `0x01` | GET_VERSION | 请求/响应 | 空 | N 字节,见 §4.2 | |
|||
| `0x02` | GET_PRODUCT_ID | 请求/响应 | 空 | N 字节 | |
|||
| `0x03` | GET_COLOR_ID | 请求/响应 | 空 | 1 字节 | |
|||
| `0x04` | BATTERY_INFO | 请求/响应 **+ 主动上报** | 空 | 3 字节,见 §4.3 | |
|||
| `0x05` | CONTROL_CODEC | 请求/响应 | 2 字节 | 2 字节 | |
|||
| `0x06` | VOLUME_UP | 请求/响应 | 空 | — | |
|||
| `0x07` | VOLUME_DOWN | 请求/响应 | 空 | — | |
|||
| `0x08` | PLAY_A2DP | 请求/响应 | 空 | — | |
|||
| `0x11` | WAKE_UP | **仅主动上报** | — | 空 | |
|||
| `0x21` | REC_KEY_START | **仅主动上报** | — | 空 | |
|||
| `0x22` | REC_KEY_PAUSE | **仅主动上报** | — | 空 | |
|||
| `0x23` | REC_KEY_STOP | **仅主动上报** | — | 空 | |
|||
|
|||
### 4.2 `0x01` 版本 / `0x02` 产品 ID / `0x03` 颜色 ID |
|||
|
|||
- **版本**:响应 data 每个字节是一个版本号段。解析时**丢弃开头连续的 `0xFF`**, |
|||
其余字节按十进制用 `.` 连接。例:`FF 00 00 02` → `"0.0.2"`。 |
|||
- **产品 ID**:响应 data 每字节按十进制用 `.` 连接(不丢 `0xFF`)。 |
|||
- **颜色 ID**:响应 data 第 1 字节。 |
|||
|
|||
### 4.3 `0x04` 电量 |
|||
|
|||
响应 / 主动上报的 data **至少 3 字节**: |
|||
|
|||
| 字节 | 含义 | |
|||
|---|---| |
|||
| `[0]` | 左耳 / 通道 1 | |
|||
| `[1]` | 右耳 / 通道 2 | |
|||
| `[2]` | 充电仓 / 主体 | |
|||
|
|||
每字节:**bit7 = 充电中标志,bit0~6 = 电量百分比**(取值前必须 `& 0x7F`)。 |
|||
App 侧当前把 `[2]` 当作整机电量展示。 |
|||
|
|||
- 这是**高频主动上报**(`CC 04`),App 侧已对它单独屏蔽日志。 |
|||
- `[待确认 Q4]` 支架不是耳机,三个槽位的语义要重新定义(支架本体?内置电池?外接供电?)。 |
|||
上报频率也请给一个明确值——高频上报会和文件传输抢空口,见 §8。 |
|||
|
|||
### 4.4 `0x05` 编解码控制 |
|||
|
|||
请求/响应 data 都是 **2 字节**:`[codecStatus, channelMode]`。 |
|||
|
|||
| codecStatus | 现有代码含义 | |
|||
|---|---| |
|||
| `0x00` | 关闭编解码 | |
|||
| `0xA1` | 打开**解码** | |
|||
| `0xA2` | A2DP 播放模式(同时起编码流与双声道解码) | |
|||
| `0xB1` | 打开**编码** | |
|||
|
|||
| channelMode | 含义 | |
|||
|---|---| |
|||
| `0x01` | 左声道 | |
|||
| `0x02` | 右声道 | |
|||
| `0x03` | 立体声 | |
|||
|
|||
失败时设备回 **1 字节** data,值为 `0x00`。 |
|||
|
|||
> ⚠️ **`[待确认 Q5]` 这组命名与实际方向是反的,必须在新固件上厘清。** |
|||
> 现有代码里 `openEncoder()` 发的是 `0xB1`,而它的实际作用是**让设备开始往手机推 Opus 流** |
|||
> (手机侧同时启动的是**解码**器);`openDecoder()` 发 `0xA1`,做的事几乎一样。 |
|||
> 「编码/解码」这两个词在现有实现里指的是**设备侧的动作**,与 App 侧视角相反, |
|||
> 已经造成过多次误读。新协议建议改成方向明确的命名: |
|||
> `UPLINK_ON`(设备→手机推流)/ `DOWNLINK_ON`(手机→设备放音)/ `BOTH` / `OFF`, |
|||
> 数值可保持 `0xB1 / 0xA1 / 0xA2 / 0x00` 不变以减少固件改动。 |
|||
|
|||
### 4.5 主动上报事件 |
|||
|
|||
| type | 事件 | App 侧当前行为 | |
|||
|---|---|---| |
|||
| `0x11` | 设备唤醒 | 触发唤醒回调(开启上行音频) | |
|||
| `0x21` | 按键:开始录音 | 调 `toggleRecording()` | |
|||
| `0x22` | 按键:暂停录音 | 仅记日志 | |
|||
| `0x23` | 按键:停止录音 | 调 `saveRecording()` 落盘归档 | |
|||
|
|||
> ⚠️ **带存储后这四条的语义会变,见 §7.3。** 设备现在可以**脱机录到 TF 卡**, |
|||
> 按键起停不再必然对应「App 侧开始/结束一次流式录音」。 |
|||
|
|||
### 4.6 实时音频 |
|||
|
|||
**上行(设备 → App)**:`0000ae02-…` 的 Notify,**裸 Opus 帧,无容器、无帧头,一包一帧不跨包**。 |
|||
|
|||
| 规格 | 码率 | 帧长 | 每帧字节 | 采样率 | 声道 | |
|||
|---|---|---|---|---|---| |
|||
| `LEGACY` | 16 kbps | 20 ms | **40 B** | 16 kHz | 单声道 | |
|||
| `V2` | 32 kbps | 20 ms | **80 B** | 16 kHz | 单声道 | |
|||
| A2DP 模式 | — | — | 80 B | 16 kHz | **双声道** | |
|||
|
|||
**代差判定:看 GATT 里有没有 `0x180A`(Device Information Service)。** |
|||
有 → `V2`;没有 → `LEGACY`。`0x2A26`(Firmware Revision String)只用于日志,不参与判定。 |
|||
|
|||
> `[待确认 Q6]` 新支架请**明确固定为一种规格**,不要再沿用「靠有没有 0x180A 猜码率」这种隐式约定。 |
|||
> 建议直接在 `0x01` 版本响应里加一个显式的能力字节,或固定为 `V2`。 |
|||
> 既然是全新设备且不做旧设备兼容,这里正好可以把这个历史包袱去掉。 |
|||
|
|||
**下行(App → 设备)**:`0000abc1-0001-…` 的 Write(带响应),**120 字节/块,60 ms 间隔**, |
|||
失败重试 1 次、间隔 30 ms。 |
|||
|
|||
> ⚠️ **现状提醒:Android 侧下行音频是死的。** `BleService.kt` 的 `callWriteChar` 声明了但 |
|||
> **从未赋值**——`onServicesDiscovered` 只查找了 `abc0` 和 `ae00` 两个 service,没有查找 |
|||
> `abc0-0001`。所以 `sendAudioChunk()` 永远走 `callWriteChar == null` 分支直接 return, |
|||
> Android 上从来没有真正下行播放过音频;只有 iOS 实现完整。 |
|||
> 如果新支架要用下行音频,**Android 侧这条链路需要重新接通**,不是「沿用」而是「新做」。 |
|||
|
|||
### 4.7 MTU |
|||
|
|||
- App 请求 512,实际取双方 min。协商结果 App 侧可查(`getMtu()`)。 |
|||
- **iOS 无法主动请求 MTU**,只能读 `maximumWriteValueLength`。因此协议**不得依赖 App 侧指定 MTU**。 |
|||
- 通道 A 的命令帧都很短(≤ 259 B),不受 MTU 影响;MTU 只影响通道 A' 的音频吞吐与通道 B 的文件下载速度。 |
|||
|
|||
--- |
|||
|
|||
## 5. 通道 B:TF 卡文件 `[新增]` |
|||
|
|||
以下内容来自 [recorder_protocol(2)(1).md](<recorder_protocol(2)(1).md>) 草案,**未经真机验证**。 |
|||
本节在草案基础上补齐了草案缺失的约定(标 `[补]`)。 |
|||
|
|||
### 5.1 GATT |
|||
|
|||
| 项 | UUID | |
|||
|---|---| |
|||
| Service | `0xFDA5` | |
|||
| CMD_TX(设备→App,Notify) | `0xFDA6` | |
|||
| CMD_RX(App→设备,Write / Write No Response) | `0xFDA7` | |
|||
|
|||
**文件传输用 Notify,不用 Indicate**(Indicate 每包要 ATT 确认,吞吐会掉一个数量级)。 |
|||
|
|||
### 5.2 帧格式 |
|||
|
|||
```c |
|||
#define BLE_REC_PKT_MAGIC 0xA5 |
|||
|
|||
typedef struct PACKED { |
|||
u8 magic; // 固定 0xA5 |
|||
u8 cmd; // 见 §5.4 |
|||
u8 seq; // 发送方独立递增,0xFF 回绕到 0x00 |
|||
u16 len; // payload 长度(不含本包头),little-endian |
|||
} ble_rec_pkt_hdr_t; // 5 字节 |
|||
``` |
|||
|
|||
- **所有多字节整数一律 little-endian。** |
|||
- 请求/响应不再靠帧头区分,由 **GATT 方向 + cmd** 决定。 |
|||
- `len` 必须等于 `BLE value 总长度 - 5`。**magic 不符或 len 不符的包直接丢弃。** |
|||
- 收发两个方向的 `seq` **各自独立递增**,设备**不回填**请求的 `seq`。 |
|||
- 单事务模型:App 发出一条请求后,等到对应 `cmd` 的响应再发下一条。 |
|||
- App 侧普通命令超时 **3 秒**,超时后重发当前命令。 |
|||
|
|||
> ⚠️ **`[待确认 Q7]`:`len` 是 u16,但草案没有给任何分片/重组机制。** |
|||
> 若 `len` 永远不会超过单个 BLE value 能装下的量(`att_mtu - 3 - 5`),请**在协议里写死这条约束**; |
|||
> 若可能超过,必须补充分片规则。现有工程的 BLE 层**从来没有做过重组**,这是纯新增能力。 |
|||
|
|||
> ⚠️ **`[待确认 Q8]`:`seq` 不回填请求值 = 无法做请求-响应匹配。** |
|||
> 后果是超时重发后收到的迟到旧响应,与新响应无法区分,会被当成新响应处理。 |
|||
> 文件传输动辄几分钟,这个风险比命令通道大得多。**强烈建议响应回填请求的 seq。** |
|||
|
|||
### 5.3 状态码 |
|||
|
|||
```c |
|||
BLE_REC_ST_OK = 0x00 |
|||
BLE_REC_ST_PARAM_ERR = 0x01 |
|||
BLE_REC_ST_BUSY = 0x02 |
|||
BLE_REC_ST_NO_CARD = 0x03 |
|||
BLE_REC_ST_FS_ERR = 0x04 |
|||
BLE_REC_ST_FILE_NOT_FOUND = 0x05 |
|||
BLE_REC_ST_ACCESS_DENIED = 0x06 |
|||
BLE_REC_ST_UNSUPPORTED = 0x07 |
|||
BLE_REC_ST_NO_SPACE = 0x08 |
|||
BLE_REC_ST_CANCELLED = 0x09 |
|||
``` |
|||
|
|||
### 5.4 命令总览 |
|||
|
|||
| cmd | 名称 | 说明 | 首版 | |
|||
|---:|---|---|---| |
|||
| `0x01` | REC_INFO | 查 TF 信息 + 录音状态;状态变化时设备主动上报 | ✅ 必做 | |
|||
| `0x02` | FILE_LIST | 获取完整文件列表 | ✅ 必做 | |
|||
| `0x03` | FILE_INFO | 查询单个文件信息 | 可选 | |
|||
| `0x04` | FILE_TRANSFER | 文件传输(设备连续 Notify) | ✅ 必做 | |
|||
| `0x05` | FILE_DELETE | 删除文件 | 第二步 | |
|||
| `0x06` | REC_FORMAT | 格式化 TF 卡 | 第二步 | |
|||
| `0x07` | RTC_SYNC | App 同步设备 RTC | ✅ 必做 | |
|||
|
|||
### 5.5 文件与路径约定 |
|||
|
|||
- 文件固定在 TF 卡 **`\REC\`** 目录下。**App 只传文件名,不传路径。** |
|||
- 首版固定 **8.3 短文件名,大写 ASCII**,例:`MIC0016.OGG`。 |
|||
- 所有变长 `name[]` **必须以 `\0` 结尾**;`name_len` 是 `name[]` 总长度**含结尾 `\0`** |
|||
(`MIC0016.OGG` → `name_len = 12`)。 |
|||
- 接收方**必须同时校验** `name_len ≥ 2`、最后一字节为 `0x00`、内容不含 `\` 或 `/`, |
|||
任一不满足按 `PARAM_ERR` 处理。**App 侧也要做这套校验**,防止固件返回脏数据导致本地写到目录外。 |
|||
- 文件顺序由 FAT 目录项扫描顺序决定,**不保证有序**,App 本地排序。 |
|||
|
|||
### 5.6 `0x01` REC_INFO |
|||
|
|||
```c |
|||
// 请求:len = 0,无 payload |
|||
|
|||
// 响应 payload: |
|||
u8 status; // BLE_REC_ST_xxx |
|||
u8 fs_type; // 1:FAT12 2:FAT16 3:FAT32 4:exFAT |
|||
u32 total_kb; // TF 总容量,KB |
|||
u32 used_kb; // 已用容量,KB(建议 total - free) |
|||
u8 rec_state; // 0:已停止 1:录音中 |
|||
``` |
|||
|
|||
- `rec_state` **始终有效**。`status != OK` 时 App 仍采信 `rec_state`,其余字段按 0 处理。 |
|||
- **录音状态变化时设备主动 Notify 本响应**,`status = OK`,容量取最近一次成功查询的缓存值。 |
|||
- **录音停止后,设备紧接着主动推一次完整 `FILE_LIST`**,用于刷新新生成的录音文件。 |
|||
- `[补]` `[待确认 Q9]` TF 卡热插拔没有主动上报,`NO_CARD` 只出现在响应里。 |
|||
建议增加插拔事件的主动上报,否则 App 只能靠轮询发现。 |
|||
|
|||
### 5.7 `0x02` FILE_LIST |
|||
|
|||
App 只发一次请求,设备返回全部条目;一包放不下就连续 Notify 多包,直到 `eof = 1`。 |
|||
|
|||
```c |
|||
// 请求:len = 0 |
|||
|
|||
// 响应 payload: |
|||
u8 status; |
|||
u8 item_count; // 本包 item 数 |
|||
u8 eof; // 1 = 列表结束 |
|||
u8 items[]; // ble_rec_file_item_t 数组,逐项变长解析 |
|||
|
|||
// item: |
|||
u32 size; // 文件大小 |
|||
u32 ftime; // FAT 时间 |
|||
u8 name_len; // 含结尾 '\0' |
|||
u8 name[]; // 变长 |
|||
``` |
|||
|
|||
- **单个 item 不跨 BLE 包**:剩余空间放不下完整 item 时,顺延到下一个响应包。 |
|||
- 每个 item 实际长度 = `4 + 4 + 1 + name_len`。 |
|||
- `\REC\` 不存在 → `status = OK, item_count = 0, eof = 1`。 |
|||
- TF 卡不存在 → `status = NO_CARD, item_count = 0, eof = 1`。 |
|||
- App 解析时必须同时校验 `hdr.len` 与 `item_count`,防越界。 |
|||
- 增删文件后 App 重新拉一次完整列表(无增量接口)。 |
|||
|
|||
> ⚠️ **`[待确认 Q10]`:列表里没有「哪个文件正在被写入」的标记。** |
|||
> `REC_INFO.rec_state` 只给全局状态。录音进行中时,正在写的那个文件能否被下载/删除? |
|||
> 建议在 item 里加一个 flags 字节,至少标出 `IN_USE`。 |
|||
|
|||
> ⚠️ **`[待确认 Q11]`:item 里没有音频时长。** |
|||
> App 归档时需要 `seconds` 字段(写入服务端录音记录)。目前只能靠本地解 Ogg 页数反算, |
|||
> 成本高且易错。**强烈建议在 item 末尾扩展一个 `u32 duration_ms`。** |
|||
|
|||
### 5.8 `0x03` FILE_INFO(可选) |
|||
|
|||
```c |
|||
// 请求 payload:u8 name_len; u8 name[]; |
|||
// 响应 payload:u8 status; u32 size; u32 ftime; |
|||
``` |
|||
|
|||
`status != OK` 时其余字段按 0 处理。首版可不实现(`FILE_LIST` 已含同样信息)。 |
|||
|
|||
### 5.9 `0x04` FILE_TRANSFER |
|||
|
|||
单命令,用 `action_cmd` 区分子操作。**核心策略:App 发一次 SETUP,设备连续 Notify DATA,App 不逐包索要。** |
|||
|
|||
```c |
|||
// payload 首字节: |
|||
u8 action_cmd; |
|||
// 0x01 SETUP App → 设备 |
|||
// 0x02 SETUP_RSP 设备 → App |
|||
// 0x03 DATA 设备 → App |
|||
// 0x04 CANCEL 双向 |
|||
|
|||
// SETUP(App → 设备): |
|||
u32 start_ofs; // 起始偏移,断点续传用 |
|||
u8 name_len; |
|||
u8 name[]; |
|||
|
|||
// SETUP_RSP(设备 → App): |
|||
u8 status; |
|||
u32 file_size; // 文件总大小 |
|||
u32 start_ofs; // 实际起始偏移 |
|||
u32 ftime; |
|||
u16 data_len; // 设备采用的单包 data 长度 |
|||
|
|||
// DATA(设备 → App): |
|||
u32 file_ofs; // 本包 data 在文件中的偏移 |
|||
u8 control; // 0x00 继续 / 0x01 本包后结束 |
|||
u16 sum; // 校验和,见下 |
|||
u8 data[]; |
|||
|
|||
// CANCEL(双向): |
|||
u8 reason; // 0 用户取消 / 1 offset 异常 / 2 超时 / 3 设备错误 |
|||
``` |
|||
|
|||
**单包 data 长度**:设备自行按当前 MTU 计算,协议不协商。 |
|||
|
|||
``` |
|||
data_max = att_mtu - 3 - 5(hdr) - 1(action) - 4(ofs) - 1(control) - 2(sum) |
|||
= att_mtu - 16 |
|||
``` |
|||
MTU 247 时为 231 字节。设备可再取 `min(data_max, 512)`。 |
|||
|
|||
**App 解析 DATA 的实际数据长度必须用 `hdr.len - 8`,不能用 `SETUP_RSP.data_len`** |
|||
(最后一包通常更短)。 |
|||
|
|||
**校验和 `sum`**:整个 BLE value(从 `magic` 到 `data[]` 末字节)逐字节累加的 u16, |
|||
计算时 `sum` 字段自身两字节按 0 处理。这是轻量 sanity check,不替代链路层 CRC。 |
|||
|
|||
> ⚠️ **`[待确认 Q12]`:草案留了「`sum = 0` 表示不启用校验」的后门,必须在冻结前定死。** |
|||
> 合法数据恰好算出 0 时会被误判成「未启用」。建议**取消这个后门**,或改用一个独立的 flag 位。 |
|||
> 另注意实现时**校验范围包含包头**,最容易写错成只算 `data[]`。 |
|||
|
|||
**设备侧发送策略**: |
|||
- 目录或文件不存在 → `SETUP_RSP.status = FILE_NOT_FOUND`。 |
|||
- SETUP 成功后尽快连续发 DATA,能发就发,直到 TX 队列满 / 文件结束 / 收到 CANCEL / 文件系统错误。 |
|||
- TX 队列满时**不要忙等**,等 notify complete / TX credit 回调后继续。 |
|||
- 文件系统错误 → 发 `CANCEL, reason = 3`。 |
|||
- 长文件循环中**定期喂狗**。 |
|||
- 最后一包 `control = 0x01`;文件大小恰好对齐时可发一个 0 字节的 END 包。 |
|||
- **同一时间只允许一个传输在进行**,DATA/CANCEL 默认作用于当前传输。 |
|||
- 传输结束/取消/超时/BLE 断开后,设备清空传输状态;续传需重新 SETUP。 |
|||
|
|||
**App 侧接收策略**: |
|||
- 按 `file_ofs` 写入本地文件,重复包按 offset 去重。 |
|||
- `file_ofs` 与期望值不符 → 发 CANCEL,然后从期望 offset 重新 SETUP。 |
|||
- **超过 5 秒**没收到新 DATA 或 CANCEL → 判超时,从已保存 offset 重新 SETUP 续传。 |
|||
- 收到 `control = END` 后校验本地文件大小 == `file_size`。 |
|||
|
|||
### 5.10 `0x05` FILE_DELETE |
|||
|
|||
```c |
|||
#define BLE_REC_DELETE_CONFIRM 0x44454C21 // "DEL!" |
|||
// 请求 payload:u32 confirm; u8 name_len; u8 name[]; |
|||
// 响应 payload:u8 status; |
|||
``` |
|||
|
|||
- 录音中 / 传输中 → `BUSY`(或要求 App 先 CANCEL)。 |
|||
- 目录或文件不存在 → `FILE_NOT_FOUND`。 |
|||
- **App UI 必须二次确认。** |
|||
|
|||
### 5.11 `0x06` REC_FORMAT |
|||
|
|||
```c |
|||
#define BLE_REC_FORMAT_CONFIRM 0x464D5421 // "FMT!" |
|||
// 请求 payload:u32 confirm; u8 fs_type; // 0 = 自动 |
|||
// 响应 payload:u8 status; |
|||
``` |
|||
|
|||
- 录音中 / 传输中 → `BUSY`;低电量 → `ACCESS_DENIED`;无卡 → `NO_CARD`。 |
|||
- **App UI 必须二次确认。** |
|||
- `[补]` **格式化是不可逆的用户数据销毁操作**,App 侧应在确认弹窗里显式说明将删除多少个文件。 |
|||
|
|||
### 5.12 `0x07` RTC_SYNC |
|||
|
|||
```c |
|||
// 请求 payload: |
|||
u16 year; // 1980~2106 |
|||
u8 month; // 1~12 |
|||
u8 day; // 1~31(设备按年月校验) |
|||
u8 hour; // 0~23 |
|||
u8 minute; // 0~59 |
|||
u8 second; // 0~59 |
|||
// 响应 payload:u8 status; |
|||
``` |
|||
|
|||
- **时间按设备本地时间理解,不做时区协商。** |
|||
- **App 在每次 BLE 连接成功后主动同步一次**,再做其它查询/下载。成本低,照做。 |
|||
- 设备 RTC 未同步或时间无效时,录音文件创建时间回退到旧的递增时间策略,避免写入非法 FAT 时间。 |
|||
- `[补]` 跨时区后设备时间会与手机不一致,且 FAT `ftime` 不带时区信息。 |
|||
App 展示录音时间时**一律按「设备本地时间」的字面值显示,不做任何时区换算**。 |
|||
|
|||
--- |
|||
|
|||
## 6. 通道 B 的音频格式 |
|||
|
|||
设备录到 TF 卡的是 **`.OGG`(Ogg 封装的 Opus)**,是设备侧已封装好的完整文件。 |
|||
|
|||
- App 拉下来**不需要解码**,直接存档即可——比通道 A' 那条「收裸 Opus 帧 → 解码 → 写 WAV」的链路简单得多。 |
|||
- 服务端上传扩展名白名单**已包含 `.ogg`**(`audio/ogg`),无需改后端。 |
|||
- `[待确认 Q13]` 请确认 TF 卡上的实际编码参数(采样率/码率/声道), |
|||
以及是否与通道 A' 实时流的参数一致。转写服务对采样率有要求。 |
|||
|
|||
--- |
|||
|
|||
## 7. 两条通道并存的约束 `[新增·本文档补充]` |
|||
|
|||
草案完全没有涉及这部分,但它是新设备最容易出事的地方。 |
|||
|
|||
### 7.1 互斥规则 |
|||
|
|||
| 场景 | 规则 | |
|||
|---|---| |
|||
| 文件传输进行中 | **禁止**下发通道 A 的任何非必要命令;设备侧的电量高频上报建议**降频或暂停** | |
|||
| 实时音频推流中 | **禁止**发起文件传输(空口带宽会互相抢,两边都卡) | |
|||
| 录音进行中(设备本体) | 允许查询 `REC_INFO` / `FILE_LIST`;**是否允许下载/删除见 Q10** | |
|||
| 格式化 / 删除 | 与上述一切互斥,设备回 `BUSY` | |
|||
|
|||
> App 侧的现有实现有一套「命令队列 + 回包超时 + 串行下发」机制**只覆盖通道 A**。 |
|||
> 通道 B 必须有自己独立的一套,且两者之间要有一个**全局互斥闸**—— |
|||
> 否则文件传输跑了几分钟,中间被一条电量查询插进去,设备端「同一时间只允许一个传输」的 |
|||
> 约束就被打断了。 |
|||
|
|||
### 7.2 吞吐与用户预期 |
|||
|
|||
- 231 B/包、7.5 ms 连接间隔的理想值约 **25 KB/s**,实际通常更低。 |
|||
- 一张 128 MB 卡装满,全量下载是**小时级**。 |
|||
- App 侧必须提供:进度、可取消、断点续传(协议已支持)、后台续传(复用现有前台服务,别新起一套)。 |
|||
- `[待确认 Q14]` 请给出实测吞吐值(KB/s),以及固件是否支持 2M PHY / Data Length Extension。 |
|||
|
|||
### 7.3 录音状态机的归属要重新划分 |
|||
|
|||
这是换设备后**App 侧改动最大的地方**: |
|||
|
|||
| | 旧支架 | 新支架 | |
|||
|---|---|---| |
|||
| 谁决定在录音 | **App**(发 `0x05` 开推流,自己数时长、自己落盘) | **设备**(可脱机录到 TF 卡,App 只是观察者) | |
|||
| 录音数据在哪 | 手机本地 WAV,边收边写 | TF 卡 `.OGG`,事后拉 | |
|||
| App 怎么知道在录 | 自己发的命令 | 通道 B 的 `REC_INFO` 主动上报(`rec_state`) | |
|||
| 通道 A 的 `CC 21/22/23` | 驱动 App 起停录音 | **语义重叠了**:设备按键既触发它自己的 TF 录音,又会让 App 以为要起流式录音 | |
|||
|
|||
> ⚠️ **`[待确认 Q15]`:通道 A 的 `CC 21/22/23`(按键录音事件)与通道 B 的 `REC_INFO` 主动上报, |
|||
> 是否描述的是同一件事?** 如果是,请**只保留一处**(建议保留通道 B 的 `REC_INFO`,语义更完整), |
|||
> 否则 App 会收到两套互相矛盾的状态,出现「设备在录、App 以为没录」或反之。 |
|||
|
|||
--- |
|||
|
|||
## 8. 典型时序 |
|||
|
|||
### 8.1 连接与初始化 |
|||
|
|||
``` |
|||
1. 扫描(按设备名前缀筛选) |
|||
2. 服务端 user_binddevice 登记(devicemac,code 留空) ← 失败不阻断 |
|||
3. GATT 连接 → 服务发现 |
|||
4. 使能通知:先 ae02(音频),再 abc2(控制),再 FDA6(文件) |
|||
5. 延时 1s → requestMtu(512) |
|||
6. 通道 B:RTC_SYNC |
|||
7. 通道 A:GET_VERSION / GET_BATTERY_INFO |
|||
8. 通道 B:REC_INFO → FILE_LIST |
|||
``` |
|||
|
|||
### 8.2 下载一个文件 |
|||
|
|||
``` |
|||
1. FILE_TRANSFER / SETUP (start_ofs = 0, name) |
|||
2. ← SETUP_RSP (status, file_size, start_ofs, ftime, data_len) |
|||
3. ← DATA × N(设备连续 Notify,App 按 file_ofs 落盘) |
|||
4. ← DATA (control = END) |
|||
5. App 校验本地大小 == file_size → 归档 → 上传 |
|||
``` |
|||
|
|||
### 8.3 断点续传 |
|||
|
|||
``` |
|||
1. App 本地已有 saved_offset |
|||
2. FILE_TRANSFER / SETUP (start_ofs = saved_offset, name) |
|||
3. 设备从该 offset 继续推 DATA |
|||
``` |
|||
|
|||
### 8.4 实时录音(通道 A,沿用) |
|||
|
|||
``` |
|||
1. 通道 A:CONTROL_CODEC(0xB1, channel) → 设备开始推 Opus |
|||
2. ← ae02 Notify 裸 Opus 帧(40B 或 80B/帧) |
|||
3. App 解码 → 写本地 WAV |
|||
4. 通道 A:CONTROL_CODEC(0x00, channel) → 停止 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 9. 通道 C:OTA `[待定 · 占位]` |
|||
|
|||
**本版不约束固件。** 现状是:旧支架走一条完全独立的 FOT 协议 |
|||
(Service `0xFF12`,Notify `0xFF14`,Write No Response `0xFF15`), |
|||
实现在 [lib/src/ota/](lib/src/ota/),已有单元测试 |
|||
(`test/ota/fot_frame_test.dart`、`test/ota/fot_ota_session_test.dart`)。 |
|||
|
|||
两个候选方向,待定: |
|||
|
|||
- **A. 沿用 FOT** —— 新支架继续提供 `FF12/FF14/FF15`。App 侧零改动,风险最低。 |
|||
- **B. 并入通道 B** —— 复用 `FILE_TRANSFER` 的分块/续传机制反向传固件包,新增一条 OTA 命令。 |
|||
协议更统一,但固件侧升级流程要重做,现有 FOT 代码与单测作废。 |
|||
|
|||
`[待确认 Q16]` 请固件方给出倾向,本节在下一版补全。 |
|||
|
|||
--- |
|||
|
|||
## 10. App 侧落地影响清单 |
|||
|
|||
给客户端开发看的「哪些代码要动」: |
|||
|
|||
| 模块 | 改动 | |
|||
|---|---| |
|||
| `BleConst.kt` / `BleConst.swift` | 新增 `0xFDA5/6/7` 三个 UUID | |
|||
| `BleService.onServicesDiscovered` | 新增查找 `FDA5` 服务并使能 `FDA6` 通知;**顺带补上一直缺失的 `abc0-0001` 查找**(Android 下行音频修复,见 §4.6) | |
|||
| `BleService.onCharacteristicChanged` | 已是 `when(c.uuid)` 分派,加一个 `FDA6` 分支即可;**新写一个通道 B 解析器,不要复用 `processDeviceResponse`**(len 位宽与校验方式都不同) | |
|||
| 新增 | 通道 B 的事务层:seq 管理、3s 超时、FILE_LIST 多包累积状态机、FILE_TRANSFER 会话状态机(offset 跟踪 / 5s 无数据超时 / 续传 / 取消) | |
|||
| 新增 | 通道 A / 通道 B 全局互斥闸(§7.1) | |
|||
| `HolderRecordingService` | 录音状态机从「App 主导」改为「设备主导 + App 观察」,见 §7.3 | |
|||
| 新增 UI | TF 卡文件列表页(大小/时间/下载进度/删除)、容量展示、格式化入口(二次确认) | |
|||
| `RecordingArchive` | 接入「从 TF 下载的 `.OGG`」这条来源;时长字段待 Q11 | |
|||
| `HolderDeviceService.bindToServer` | **不变**(MAC 上报,code 留空) | |
|||
| iOS | 通道 B 全部逻辑要在 `BleService.swift` 独立再实现一遍;确认后台 `bluetooth-central` 模式下长时间下载不被挂起 | |
|||
|
|||
**落地顺序建议**(与草案一致): |
|||
`RTC_SYNC` + `REC_INFO` + `FILE_LIST` + `FILE_TRANSFER` 先跑通只读链路 → 再接 `FILE_INFO` → |
|||
最后才是 `FILE_DELETE` / `REC_FORMAT` 这两个破坏性操作。 |
|||
|
|||
--- |
|||
|
|||
## 11. 待固件确认清单 |
|||
|
|||
请逐条回复,有异议的改动记进 §12。 |
|||
|
|||
| # | 章节 | 问题 | |
|||
|---|---|---| |
|||
| Q1 | §2.1 | 新支架的广播设备名前缀是什么? | |
|||
| Q2 | §2.3 | 能否增加一条「读取设备蓝牙 MAC」的命令(或在 `REC_INFO` 里带上)?iOS 拿不到 MAC,这是 iOS 侧做设备校验的唯一出路。 | |
|||
| Q3 | §3.1 | 通道 A 用 `len = 0` 表示命令失败,隐含「成功响应必须至少 1 字节」。是否本意?建议改为显式状态字节。 | |
|||
| Q4 | §4.3 | 电量三个槽位在支架上的语义是什么?主动上报频率是多少? | |
|||
| Q5 | §4.4 | `0x05` 的「编码/解码」命名与实际方向相反,建议改为 `UPLINK/DOWNLINK` 语义(数值可不变)。 | |
|||
| Q6 | §4.6 | 实时音频规格能否**固定为一种**、或在版本响应里给显式能力字节?不要再靠有无 `0x180A` 猜码率。 | |
|||
| Q7 | §5.2 | 通道 B 的 `hdr.len` 是 u16,是否可能超过单个 BLE value?若不会,请写死约束;若会,必须补分片规则。 | |
|||
| Q8 | §5.2 | 响应能否回填请求的 `seq`?不回填则无法区分迟到的旧响应。 | |
|||
| Q9 | §5.6 | TF 卡热插拔能否主动上报? | |
|||
| Q10 | §5.7 | 录音进行中,正在写的那个文件能否下载/删除?建议在 item 里加 flags 标出 `IN_USE`。 | |
|||
| Q11 | §5.7 | **能否在 file item 末尾扩展 `u32 duration_ms`?** App 归档必须要时长。 | |
|||
| Q12 | §5.9 | `sum = 0` 表示不启用校验的后门要不要保留?建议取消。 | |
|||
| Q13 | §6 | TF 卡上 `.OGG` 的实际编码参数(采样率/码率/声道)?与实时流是否一致? | |
|||
| Q14 | §7.2 | 文件下载的实测吞吐(KB/s)?是否支持 2M PHY / DLE? | |
|||
| Q15 | §7.3 | 通道 A 的 `CC 21/22/23` 与通道 B 的 `REC_INFO` 主动上报是否同一件事?建议只保留一处。 | |
|||
| Q16 | §9 | OTA 走 A(沿用 FOT)还是 B(并入文件通道)? | |
|||
|
|||
--- |
|||
|
|||
## 12. 变更记录 |
|||
|
|||
| 日期 | 版本 | 变更 | |
|||
|---|---|---| |
|||
| 2026-09-08 | v0.1 | 首版。合并现有支架代码实现(通道 A / A' / A'')与 TF 存储协议草案(通道 B),补充两通道互斥、录音状态机归属、App 落地清单与 16 条待确认项。 | |
|||