Browse Source

设备图改成按设备名从本地取;修同传页 GetX 报错遮挡与 AI 页 2px 溢出

## 设备图:关键字映射 → 按名字取图

原来是一张写死的映射表(名字里含 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
Rodger-Wang 1 month ago
parent
commit
abd0922d78
  1. BIN
      apps/client/assets/images/devices/EaiRec.png
  2. BIN
      apps/client/assets/images/devices/EaiRec_icon.png
  3. 0
      apps/client/assets/images/devices/Echo-one.png
  4. 0
      apps/client/assets/images/devices/Echo-one_icon.png
  5. BIN
      apps/client/assets/images/devices/Smartcar.png
  6. BIN
      apps/client/assets/images/devices/Smartcar_icon.png
  7. BIN
      apps/client/assets/images/devices/default.png
  8. BIN
      apps/client/assets/images/devices/default_icon.png
  9. BIN
      apps/client/assets/images/devices/record_card.jpg
  10. BIN
      apps/client/assets/images/devices/record_card.png
  11. 489
      apps/client/docs/recorder_protocol.md
  12. 48
      apps/client/lib/core/utils/device_connection.dart
  13. 42
      apps/client/lib/data/models/device_category.dart
  14. 112
      apps/client/lib/data/models/device_images.dart
  15. 5
      apps/client/lib/main.dart
  16. 8
      apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart
  17. 18
      apps/client/lib/modules/main_tab/views/tabs/device_tab.dart
  18. 35
      apps/client/lib/modules/my_devices/views/my_devices_view.dart
  19. 10
      apps/client/lib/modules/ota_upgrade/views/bes_ota_upgrade_view.dart
  20. 74
      apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart
  21. 11
      apps/client/lib/modules/translation/views/translation_view.dart
  22. 687
      apps/client/local_plugins/recorder_holder/eai_recorde_protocol.md

BIN
apps/client/assets/images/devices/EaiRec.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

BIN
apps/client/assets/images/devices/EaiRec_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

0
apps/client/assets/images/devices/earphone.png → apps/client/assets/images/devices/Echo-one.png

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

0
apps/client/assets/images/devices/earphone_icon.png → apps/client/assets/images/devices/Echo-one_icon.png

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

BIN
apps/client/assets/images/devices/Smartcar.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

BIN
apps/client/assets/images/devices/Smartcar_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
apps/client/assets/images/devices/default.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 KiB

BIN
apps/client/assets/images/devices/default_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
apps/client/assets/images/devices/record_card.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

BIN
apps/client/assets/images/devices/record_card.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

489
apps/client/docs/recorder_protocol.md

@ -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 浏览和高速下载主链路跑通,再加危险操作。

48
apps/client/lib/core/utils/device_connection.dart

@ -1,7 +1,7 @@
import 'package:echomeet_device_sdk/echomeet_device_sdk.dart';
import 'package:get/get.dart';
import '../../data/models/device_category.dart';
import '../../data/models/device_images.dart';
import '../../data/models/user_Info.dart';
import '../../data/services/bes_bluetooth_service.dart';
import '../../data/services/holder_device_service.dart';
@ -68,34 +68,34 @@ class DeviceConnection {
return '';
}
/// 当前(或最近一次)连接设备的产品主图,返回 assets 路径;
/// 从没连过非杰理设备时返回 null,由调用方决定退回网络图还是默认图。
/// 当前(或最近一次)连接设备的**产品主图**。
///
/// 设备页、固件升级页都用这一份,避免出现「设备页显示耳机、
/// 固件升级页显示录音卡片」这种对不上的情况。
static String? currentAssetImage() {
if (besConnected) {
return DeviceCategories.imageForDeviceName(
BesBluetoothService.to.connectedDeviceName.value) ??
DeviceCategories.earphoneImage;
}
if (holderConnected) {
return DeviceCategories.imageForDeviceName(
HolderDeviceService.to.connectedName) ??
DeviceCategories.phoneHolderImage;
}
// 没连着:显示最近连过的那台
/// 取图规则统一在 [DeviceImages]:按设备名找 `<设备名>.png`,找不到退默认图。
/// 设备页、固件升级页、设备管理页都走这一份,避免出现「设备页显示 A、
/// 固件升级页显示 B」这种对不上的情况。
static String currentAssetImage() => DeviceImages.imageFor(_nameForImage());
/// 同一台设备的**小图标**(列表、卡片里用,别拿主图去塞 44px 的位置)
static String currentIconImage() => DeviceImages.iconFor(_nameForImage());
/// 取图用的设备名:优先当前连着的,其次最近连过的。
///
/// 断连后仍然认「最近连过的那台」是刻意的——用户拔掉耳机不代表他换了设备,
/// 这时候把图换成通用图会让人以为设备丢了。从没连过任何设备才返回空。
static String _nameForImage() {
if (besConnected) return BesBluetoothService.to.connectedDeviceName.value;
if (holderConnected) return HolderDeviceService.to.connectedName;
if (Get.isRegistered<HolderDeviceService>()) {
final img = DeviceCategories.imageForDeviceName(
HolderDeviceService.to.lastDeviceName);
if (img != null) return img;
final n = HolderDeviceService.to.lastDeviceName;
if (n.isNotEmpty) return n;
}
if (Get.isRegistered<BesBluetoothService>() &&
BesBluetoothService.to.hasPairedDevice) {
return DeviceCategories.imageForDeviceName(
BesBluetoothService.to.lastDeviceName) ??
DeviceCategories.earphoneImage;
return BesBluetoothService.to.lastDeviceName;
}
return null;
return '';
}
/// 是否曾经连过设备(用来决定断连时要不要把图压暗)
static bool get hasKnownDevice => _nameForImage().isNotEmpty;
}

42
apps/client/lib/data/models/device_category.dart

@ -58,40 +58,11 @@ class DeviceCategories {
static const String studyMachine = 'studyMachine';
static const String noteCard = 'noteCard';
/// AI 录音卡片的实拍图。换图直接覆盖这个文件即可,代码不用动。
static const String noteCardImage = 'assets/images/devices/record_card.png';
/// AI 耳机(恒玄方案)的实拍图
static const String earphoneImage = 'assets/images/devices/earphone.png';
/// 同一张耳机实拍图的小尺寸版(111×144,25KB),用于列表/卡片里的小图标。
/// [earphoneImage] 是 892×1152 / 1.1MB 的主图,塞进 44px 的位置纯属浪费解码。
/// 换图时两张一起换:`sips -Z 144 earphone.png --out earphone_icon.png`
static const String earphoneIconImage =
'assets/images/devices/earphone_icon.png';
/// AI 手机支架(蓝讯 Echomeet)的实拍图
static const String phoneHolderImage =
'assets/images/devices/phone_holder.png';
/// 按蓝牙广播名取该设备的主图。
///
/// 设备页顶部展示的是「当前连接的这台设备」的图,所以要按设备名反查而不是
/// 写死一张。以后接入新设备,在这里加一条映射即可。
static String? imageForDeviceName(String? name) {
if (name == null || name.isEmpty) return null;
final n = name.toLowerCase();
if (n.contains('echomeet')) return phoneHolderImage;
// 'echo-one' 要写全,不能只写 'echo'——上面 echomeet(手机支架)也含 echo
if (n.contains('echo-one') ||
n.contains('deepvoice') || n.contains('deepsound') ||
n.contains('voitrans') ||
n.contains('buds') || n.contains('ai80') || n.contains('a80')) {
return earphoneImage;
}
if (n.contains('note') || n.contains('card')) return noteCardImage;
return null;
}
// ⚠️ 这里原来有一组写死的图片常量(earphoneImage / earphoneIconImage /
// noteCardImage / phoneHolderImage)和一个按关键字猜图的 imageForDeviceName()。
// 已全部删除:取图改成「按设备名找 <设备名>.png / <设备名>_icon.png」,
// 统一在 [DeviceImages]。加新产品只要按这个命名把图丢进
// assets/images/devices/ 即可,不用再回这里加一条映射。
// ⚠️ 下面的 deviceTypes / nameKeywords 是按服务端现有产品数据推测的,
// 需要产品侧确认后再定稿。未配置的品类会退回扫描全部 UUID。
@ -105,7 +76,6 @@ class DeviceCategories {
subtitle: 'catEarphoneDesc',
icon: Icons.headphones,
gradient: [Color(0xFFAB47BC), Color(0xFF7B1FA2)],
imageAsset: earphoneImage,
deviceTypes: [
1, // classicBluetoothHeadset
2, // bleBluetoothDoubleHeadset
@ -126,7 +96,6 @@ class DeviceCategories {
subtitle: 'catPhoneHolderDesc',
icon: Icons.stay_current_portrait,
gradient: [Color(0xFF42A5F5), Color(0xFF1E88E5)],
imageAsset: phoneHolderImage,
nameKeywords: ['支架', 'holder', 'stand', 'echomeet'],
),
DeviceCategory(
@ -135,7 +104,6 @@ class DeviceCategories {
subtitle: 'catNoteCardDesc',
icon: Icons.credit_card,
gradient: [Color(0xFF94A3B8), Color(0xFF64748B)],
imageAsset: noteCardImage,
nameKeywords: ['note', 'card', '录音卡'],
),
DeviceCategory(

112
apps/client/lib/data/models/device_images.dart

@ -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;
}
}

5
apps/client/lib/main.dart

@ -19,6 +19,7 @@ import 'core/theme/app_theme.dart';
import 'core/theme/theme_service.dart';
import 'core/bindings/initial_binding.dart';
import 'core/utils/logger.dart';
import 'data/models/device_images.dart';
import 'core/services/native_localization_sync.dart';
import 'core/services/ai_orb_controller.dart';
import 'data/services/login_service.dart';
@ -46,6 +47,10 @@ void main() async {
// 初始化文件日志
await Logger.initFileLog();
// 读一次产品图清单。必须在 runApp 之前 await:设备页第一帧就要取图,
// 清单没载入的话首屏会闪一下默认图再跳回产品图。
await DeviceImages.load();
// 初始化登录服务
Get.put(LoginService());

8
apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart

@ -96,7 +96,12 @@ class AiTab extends StatelessWidget {
// VIP 区外面套了一层带内边距的底色容器,
// 列宽要按「扣掉这层内边距之后」的宽度算,否则卡片会顶出底层
const double vipPad = 10.0;
final double vipInnerW = contentW - vipPad.w * 2;
// ⚠️ 描边也要扣。Border 画在 padding 内侧,左右各占 1px,
// 只扣 padding 的话列宽会比实际可用宽度多出正好 2px——
// 表现就是三张小卡右侧那条 `RIGHT OVERFLOWED BY 2.0 PIXELS` 的黄黑斜纹。
// 这个值要和下面 Border.all() 的宽度保持一致,改一处就要改两处。
const double vipBorder = 1.0;
final double vipInnerW = contentW - vipPad.w * 2 - vipBorder * 2;
final double colW = (vipInnerW - _gap.w) / 2;
// VIP 区(前 5 个)整体放大一档:大方块加高 18%,
// 右侧两个小方块跟着变高,下面那排并排方块再单独加高一点
@ -147,6 +152,7 @@ class AiTab extends StatelessWidget {
),
borderRadius: BorderRadius.circular(20.r),
border: Border.all(
width: vipBorder,
color: AppPalette.accent
.withOpacity(isDarkMode ? 0.28 : 0.16),
),

18
apps/client/lib/modules/main_tab/views/tabs/device_tab.dart

@ -4,7 +4,6 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:echomeet_device_sdk/echomeet_device_sdk.dart';
import 'package:get/get.dart';
import '../../../../data/models/device_category.dart';
import '../../../../data/models/user_Info.dart';
import '../../../../routes/app_routes.dart';
import '../../../home/controllers/home_controller.dart';
@ -179,15 +178,14 @@ class DeviceTab extends StatelessWidget {
// 按当前连接设备取主图——连哪台就显示哪台。
// 取图逻辑统一在 DeviceConnection,固件升级页用的是同一份。
return Image.asset(
DeviceConnection.currentAssetImage() ??
DeviceCategories.phoneHolderImage,
DeviceConnection.currentAssetImage(),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
);
}
if (besOn) {
return Image.asset(
DeviceConnection.currentAssetImage() ?? DeviceCategories.earphoneImage,
DeviceConnection.currentAssetImage(),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
);
@ -195,13 +193,13 @@ class DeviceTab extends StatelessWidget {
if (!connected) {
// 断连后仍显示最近连接过的那台设备的图,只是整体压暗一点表示未连接;
// 从没连过任何设备时才退回默认产品图。
final String? last = DeviceConnection.currentAssetImage();
// 压暗只对「连过、现在断了」生效;从没连过设备时展示的是通用图,
// 再压暗会让人以为是坏图。
final bool known = DeviceConnection.hasKnownDevice;
return Opacity(
opacity: last == null ? 1.0 : 0.55,
opacity: known ? 0.55 : 1.0,
child: Image.asset(
// 从没连过设备时的占位图:手机支架已从「选择设备」下架
// (2026-08-26),只剩耳机这一个品类,占位图跟着改成耳机图
last ?? DeviceCategories.earphoneImage,
DeviceConnection.currentAssetImage(),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
@ -216,7 +214,7 @@ class DeviceTab extends StatelessWidget {
// 网络图加载失败/加载中都退回本地参考图,避免出现破图图标;
// 手机支架已下架,通用兜底图统一用耳机图
errorBuilder: (_, __, ___) => Image.asset(
DeviceCategories.earphoneImage,
DeviceConnection.currentAssetImage(),
fit: BoxFit.contain,
),
loadingBuilder: (context, child, progress) => progress == null

35
apps/client/lib/modules/my_devices/views/my_devices_view.dart

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../../data/models/device_images.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
@ -363,23 +364,35 @@ class MyDevicesView extends GetView<MyDevicesController> {
children: [
Row(
children: [
// 产品小图标(<设备名>_icon.png,取图规则见 DeviceImages)。
// ⚠️ 这里用的是**服务端下发的** device.devicename,不是蓝牙广播名——
// 这一页列的是「绑定关系」,可能包含此刻没连上、甚至不在身边的设备,
// 拿当前连接的名字去取图会让所有行显示成同一台。
Container(
width: 44.w,
height: 44.w,
padding: EdgeInsets.all(4.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.r),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: connected
? [connectedColor, const Color(0xFF16A34A)]
: [const Color(0xFF6366F1), const Color(0xFF8B5CF6)],
),
// 底色仍然区分连接状态:换成实拍图之后,原来靠渐变色
// 传达的「这台连着」就没了,得留个视觉线索。
color: connected
? connectedColor.withValues(alpha: 0.12)
: (isDark
? Colors.white.withValues(alpha: 0.06)
: const Color(0xFF6366F1).withValues(alpha: 0.08)),
),
child: Icon(
category.icon,
color: Colors.white,
size: 22.sp,
child: Image.asset(
DeviceImages.iconFor(device.devicename),
fit: BoxFit.contain,
// 图缺失时退回品类图标,不让整行塌成红色报错块
errorBuilder: (_, __, ___) => Icon(
category.icon,
color: connected
? connectedColor
: const Color(0xFF6366F1),
size: 22.sp,
),
),
),
SizedBox(width: 14.w),

10
apps/client/lib/modules/ota_upgrade/views/bes_ota_upgrade_view.dart

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import '../../../core/utils/device_connection.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../../data/models/device_category.dart';
import '../controllers/bes_ota_upgrade_controller.dart';
class BesOtaUpgradeView extends GetView<BesOtaUpgradeController> {
@ -96,9 +96,9 @@ class BesOtaUpgradeView extends GetView<BesOtaUpgradeController> {
return _card(
child: Row(
children: [
// 耳机实拍图(小尺寸版),比通用耳机图标更能对上用户手里那台设备。
// 用 earphoneIconImage 而不是主图 earphoneImage——后者 892×1152/1.1MB,
// 解到 44px 纯属浪费。
// 当前设备的产品小图标,比通用耳机图标更能对上用户手里那台设备。
// 取的是 <设备名>_icon.png 而不是主图——主图是近千 KB 的实拍图,
// 解到 44px 纯属浪费。取图规则见 DeviceImages。
Container(
width: 44.w,
height: 44.w,
@ -108,7 +108,7 @@ class BesOtaUpgradeView extends GetView<BesOtaUpgradeController> {
borderRadius: BorderRadius.circular(12.r),
),
child: Image.asset(
DeviceCategories.earphoneIconImage,
DeviceConnection.currentIconImage(),
fit: BoxFit.contain,
// 图缺失时退回通用图标,不让整张卡片塌成红色报错块
errorBuilder: (_, __, ___) =>

74
apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart

@ -5,7 +5,6 @@ import 'package:get/get.dart';
import '../controllers/ota_upgrade_controller.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/models/device_category.dart';
class OtaUpgradeView extends GetView<OtaUpgradeController> {
const OtaUpgradeView({Key? key}) : super(key: key);
@ -191,69 +190,26 @@ class OtaUpgradeView extends GetView<OtaUpgradeController> {
),
child: GetBuilder<OtaUpgradeController>(
builder: (controller) {
// 先按当前(或最近)连接的设备取产品图——恒玄耳机、蓝讯支架都不在
// 杰理产品表里,拿不到网络图,之前一路退到录音卡片,和设备页对不上。
final assetImage = DeviceConnection.currentAssetImage();
if (assetImage != null) {
return ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: SizedBox(
width: 120.h,
height: 120.h,
child: Image.asset(assetImage, fit: BoxFit.contain),
),
);
}
final imagePath = controller.lastConnectedDeviceImage;
if (imagePath == null || imagePath.isEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: SizedBox(
width: 120.h,
height: 120.h,
child: Image.asset(
// 手机支架已下架,通用兜底图统一用耳机图,和设备页保持一致
DeviceCategories.earphoneImage,
// 产品图有留白,用 contain 等比缩放,cover 会把机身裁掉
fit: BoxFit.contain,
),
),
);
}
// 网络图片(正方形裁剪)
// 一律按设备名读本地产品小图(<设备名>_icon.png,规则见 DeviceImages)。
//
// ⚠️ 这里原来会先试服务端下发的 productimage 网络图,已去掉:
// 产品图统一走本地资源(2026-09-08 定的),保留网络图会出现
// 「同一台设备在设备页显示本地图、在这里显示另一张网络图」。
// 顺带也省掉了加载中转圈和破图兜底那一堆分支。
return ClipRRect(
borderRadius: BorderRadius.circular(16.r),
borderRadius: BorderRadius.circular(12.r),
child: SizedBox(
width: 120.h,
height: 120.h,
child: Image.network(
imagePath,
child: Image.asset(
DeviceConnection.currentIconImage(),
// 产品图有留白,用 contain 等比缩放,cover 会把机身裁掉
fit: BoxFit.contain,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return SizedBox(
height: 100.h,
child: Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
strokeWidth: 2,
color: isDarkMode ? Colors.white : Colors.blue,
),
),
);
},
errorBuilder: (context, error, stackTrace) {
return Image.asset(
DeviceCategories.earphoneImage,
fit: BoxFit.contain,
);
},
errorBuilder: (_, __, ___) => Icon(
Icons.headphones,
size: 48.h,
color: isDarkMode ? Colors.white54 : Colors.blue,
),
),
),
);

11
apps/client/lib/modules/translation/views/translation_view.dart

@ -343,9 +343,16 @@ class TranslationView extends GetView<TranslationController> {
// 小窗样式设置。单独一颗按钮而不是长按同一颗——长按是隐藏
// 交互,用户发现不了,而这两项恰恰是需要一边看效果一边调的。
Obx(() {
// ⚠️ 可观测变量必须在平台判断**之前**读。
// 写成 `Platform.isIOS && controller.currentMode.value == ...`
// 时,Dart 的 && 在安卓上短路,右半边压根不执行,这个 Obx 就
// 一个可观测变量都没订阅到——GetX 判定「用法不当」直接抛错,
// Flutter 用错误控件顶替整条 AppBar,表现为标题和返回键被一块
// 灰/红色的框盖住(release 是灰的,看着像一层阴影)。
// 只在安卓复现:iOS 上 Platform.isIOS 为真,右半边照常执行。
final mode = controller.currentMode.value;
if (Platform.isIOS &&
(controller.currentMode.value == 'call' ||
controller.currentMode.value == 'audioVideo')) {
(mode == 'call' || mode == 'audioVideo')) {
return IconButton(
icon: Icon(
Icons.tune,

687
apps/client/local_plugins/recorder_holder/eai_recorde_protocol.md

@ -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 条待确认项。 |
Loading…
Cancel
Save