diff --git a/apps/client/assets/images/devices/EaiRec.png b/apps/client/assets/images/devices/EaiRec.png new file mode 100644 index 00000000..14a3203b Binary files /dev/null and b/apps/client/assets/images/devices/EaiRec.png differ diff --git a/apps/client/assets/images/devices/EaiRec_icon.png b/apps/client/assets/images/devices/EaiRec_icon.png new file mode 100644 index 00000000..c0620f7c Binary files /dev/null and b/apps/client/assets/images/devices/EaiRec_icon.png differ diff --git a/apps/client/assets/images/devices/earphone.png b/apps/client/assets/images/devices/Echo-one.png similarity index 100% rename from apps/client/assets/images/devices/earphone.png rename to apps/client/assets/images/devices/Echo-one.png diff --git a/apps/client/assets/images/devices/earphone_icon.png b/apps/client/assets/images/devices/Echo-one_icon.png similarity index 100% rename from apps/client/assets/images/devices/earphone_icon.png rename to apps/client/assets/images/devices/Echo-one_icon.png diff --git a/apps/client/assets/images/devices/Smartcar.png b/apps/client/assets/images/devices/Smartcar.png new file mode 100644 index 00000000..a74e1db0 Binary files /dev/null and b/apps/client/assets/images/devices/Smartcar.png differ diff --git a/apps/client/assets/images/devices/Smartcar_icon.png b/apps/client/assets/images/devices/Smartcar_icon.png new file mode 100644 index 00000000..4e7bcdc7 Binary files /dev/null and b/apps/client/assets/images/devices/Smartcar_icon.png differ diff --git a/apps/client/assets/images/devices/default.png b/apps/client/assets/images/devices/default.png new file mode 100644 index 00000000..8c08abf1 Binary files /dev/null and b/apps/client/assets/images/devices/default.png differ diff --git a/apps/client/assets/images/devices/default_icon.png b/apps/client/assets/images/devices/default_icon.png new file mode 100644 index 00000000..98bb312f Binary files /dev/null and b/apps/client/assets/images/devices/default_icon.png differ diff --git a/apps/client/assets/images/devices/record_card.jpg b/apps/client/assets/images/devices/record_card.jpg deleted file mode 100644 index dc40badd..00000000 Binary files a/apps/client/assets/images/devices/record_card.jpg and /dev/null differ diff --git a/apps/client/assets/images/devices/record_card.png b/apps/client/assets/images/devices/record_card.png deleted file mode 100644 index 1b846bac..00000000 Binary files a/apps/client/assets/images/devices/record_card.png and /dev/null differ diff --git a/apps/client/docs/recorder_protocol.md b/apps/client/docs/recorder_protocol.md new file mode 100644 index 00000000..cd713f49 --- /dev/null +++ b/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 浏览和高速下载主链路跑通,再加危险操作。 diff --git a/apps/client/lib/core/utils/device_connection.dart b/apps/client/lib/core/utils/device_connection.dart index 0c9bf1d8..d0e1614f 100644 --- a/apps/client/lib/core/utils/device_connection.dart +++ b/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()) { - 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.to.hasPairedDevice) { - return DeviceCategories.imageForDeviceName( - BesBluetoothService.to.lastDeviceName) ?? - DeviceCategories.earphoneImage; + return BesBluetoothService.to.lastDeviceName; } - return null; + return ''; } + + /// 是否曾经连过设备(用来决定断连时要不要把图压暗) + static bool get hasKnownDevice => _nameForImage().isNotEmpty; } diff --git a/apps/client/lib/data/models/device_category.dart b/apps/client/lib/data/models/device_category.dart index b3e29be7..6bb8b5a8 100644 --- a/apps/client/lib/data/models/device_category.dart +++ b/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( diff --git a/apps/client/lib/data/models/device_images.dart b/apps/client/lib/data/models/device_images.dart new file mode 100644 index 00000000..45499c62 --- /dev/null +++ b/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 _mainNames = {}; + + /// 目录下实际存在的全部条目(含 `_icon`),用来判断小图标在不在 + static final Set _allNames = {}; + + static bool _loaded = false; + + /// 读一次 AssetManifest,把目录里实际有哪些图记下来。 + /// + /// 为什么要这一步:Flutter 没法同步判断某个 asset 存不存在。不预先知道 + /// 有哪些图,就只能靠 `Image.asset` 的 errorBuilder 在渲染失败时兜底—— + /// 那是**静默**的,美术漏出一张图或者命名对不上,界面直接变成通用图, + /// 没有任何地方能看出来。有了清单就能在解析时打一条 warn。 + static Future 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; + } +} diff --git a/apps/client/lib/main.dart b/apps/client/lib/main.dart index 1cad995f..a05db60d 100644 --- a/apps/client/lib/main.dart +++ b/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()); diff --git a/apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart b/apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart index aa516eef..3fbccb0a 100644 --- a/apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart +++ b/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), ), diff --git a/apps/client/lib/modules/main_tab/views/tabs/device_tab.dart b/apps/client/lib/modules/main_tab/views/tabs/device_tab.dart index 6cf76138..2ec9333a 100644 --- a/apps/client/lib/modules/main_tab/views/tabs/device_tab.dart +++ b/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 diff --git a/apps/client/lib/modules/my_devices/views/my_devices_view.dart b/apps/client/lib/modules/my_devices/views/my_devices_view.dart index df49d192..06520a06 100644 --- a/apps/client/lib/modules/my_devices/views/my_devices_view.dart +++ b/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 { 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), diff --git a/apps/client/lib/modules/ota_upgrade/views/bes_ota_upgrade_view.dart b/apps/client/lib/modules/ota_upgrade/views/bes_ota_upgrade_view.dart index 0efd749f..b4045473 100644 --- a/apps/client/lib/modules/ota_upgrade/views/bes_ota_upgrade_view.dart +++ b/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 { @@ -96,9 +96,9 @@ class BesOtaUpgradeView extends GetView { 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 { borderRadius: BorderRadius.circular(12.r), ), child: Image.asset( - DeviceCategories.earphoneIconImage, + DeviceConnection.currentIconImage(), fit: BoxFit.contain, // 图缺失时退回通用图标,不让整张卡片塌成红色报错块 errorBuilder: (_, __, ___) => diff --git a/apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart b/apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart index 0597ef02..572dcc74 100644 --- a/apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart +++ b/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 { const OtaUpgradeView({Key? key}) : super(key: key); @@ -191,69 +190,26 @@ class OtaUpgradeView extends GetView { ), child: GetBuilder( 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, + ), ), ), ); diff --git a/apps/client/lib/modules/translation/views/translation_view.dart b/apps/client/lib/modules/translation/views/translation_view.dart index 4b12ed40..b0e16d90 100644 --- a/apps/client/lib/modules/translation/views/translation_view.dart +++ b/apps/client/lib/modules/translation/views/translation_view.dart @@ -343,9 +343,16 @@ class TranslationView extends GetView { // 小窗样式设置。单独一颗按钮而不是长按同一颗——长按是隐藏 // 交互,用户发现不了,而这两项恰恰是需要一边看效果一边调的。 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, diff --git a/apps/client/local_plugins/recorder_holder/eai_recorde_protocol.md b/apps/client/local_plugins/recorder_holder/eai_recorde_protocol.md new file mode 100644 index 00000000..76ecdf50 --- /dev/null +++ b/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]() | 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 00 `,即 `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]() 草案,**未经真机验证**。 +本节在草案基础上补齐了草案缺失的约定(标 `[补]`)。 + +### 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 条待确认项。 |