uniapp_code/APP蓝牙传图接口说明.md

595 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# BLE 蓝牙传图接口说明
> 本文档供 APP 开发工程师参考,描述手机端与 ESP32-S3 设备端的 BLE 图片传输协议。
> 设备固件ESP-IDF 5.4 + Bluedroid BLE GATT Server
> 适用平台Android + iOSFlutter 跨平台框架)
> 更新日期2026-02-27
---
## 一、设备信息
| 项目 | 值 |
|------|-----|
| 设备名称 | `Airhub_xx:xx:xx:xx:xx:xx`(动态名称,后缀为 BLE MAC 地址) |
| 广播类型 | ADV_IND可连接、可扫描 |
| 广播间隔 | 20ms |
| MTU | 512 字节设备端已设置APP 端连接后必须协商) |
| 屏幕分辨率 | 360×360 圆形 LCD |
| 图片格式 | JPEGBaseline |
| 存储空间 | SPIFFS 2MB |
| 厂商标识 | Scan Response 厂商字段 Company ID `0x4C44` + ASCII `dzbj` |
### 广播数据结构
设备广播分为两部分:
**ADV 数据(主广播包):**
```
02 01 06 — Flags: LE General Discoverable + BR/EDR Not Supported
1C 09 41 69 72 68 75 62 5F xx xx ... — 完整设备名: "Airhub_xx:xx:xx:xx:xx:xx"(长度动态)
```
**Scan Response 数据(扫描响应包):**
```
07 FF 4C 44 64 7A 62 6A — 厂商数据: Company ID 0x4C44 + "dzbj"
03 03 00 0B — 16-bit 服务 UUID: 0x0B00
```
**设备识别方法**:解析 Scan Response 厂商数据段,跳过前 2 字节 Company ID取后 4 字节判断是否为 ASCII `dzbj``0x64 0x7A 0x62 0x6A`)。也可通过设备名称前缀 `Airhub_` 辅助识别。
---
## 二、BLE 服务与特征
设备仅注册一个自定义服务:
```
图片传输服务 (Service UUID: 0x0B00)
├── 图片写入特征 (Characteristic UUID: 0x0B01)
│ ├── 属性: Write + Write Without Response
│ ├── 最大长度: 512 字节
│ └── 用途: 传输图片数据(前序帧 + 分包数据帧)
└── 图片管理特征 (Characteristic UUID: 0x0B02)
├── 属性: Write + Write Without Response
├── 最大长度: 24 字节
└── 用途: 管理命令(设置当前表盘 / 删除图片)
```
> **重要**
> - 两个特征均支持 Write 和 Write Without Response**推荐使用 Write Without ResponsewriteNoResponse** 以提升传输速度
> - APP 端发现服务和特征时,**必须通过 UUID 匹配**`0x0B00` / `0x0B01` / `0x0B02`),不要用数组索引,不同固件版本的服务/特征顺序可能不同
> - 16-bit UUID 在 BLE 协议栈中会被扩展为 128-bit 格式,例如 `0x0B00` → `00000b00-0000-1000-8000-00805f9b34fb`
---
## 三、连接流程
```
1. 初始化蓝牙适配器openBluetoothAdapter
2. 扫描设备(通过厂商数据 "dzbj" 或设备名前缀 "Airhub_" 识别目标设备)
3. 建立 BLE 连接createBLEConnection
4. 协商 MTU = 512setBLEMTU ← 必须!否则每包只能发 20 字节
5. 发现服务,通过 UUID 匹配 0x0B00
6. 发现特征,通过 UUID 匹配 0x0B01写入和 0x0B02管理
7. 就绪,可以传输图片或发送管理命令
```
---
## 四、图片传输协议(写入特征 0x0B01
### 4.1 关键参数
| 参数 | 值 | 说明 |
|------|-----|------|
| MTU | 512 | 设备端设置APP 端连接后协商 |
| ATT 有效载荷 | **509** | MTU - 3ATT 协议头占 3 字节) |
| 帧头大小 | 2 | 包序号(1) + 结束标志(1) |
| **CHUNK_SIZE** | **507** | 509 - 2 = 每包最大图片数据 |
| 写入方式 | writeNoResponse | Write Without Response不等 ACK |
| 包间延时 | **5ms** | 防止 Android BLE 队列溢出 |
| 前序帧延时 | **50ms** | 前序帧后等待设备端建立接收通道 |
> **严格约束:每个 BLE 数据包总长不得超过 509 字节MTU - 3**
> 超出此限制时Android 系统会**静默截断**数据包不报错导致设备端接收到的数据不完整JPEG 解码失败。
### 4.2 前序帧(固定 26 字节,每次传图发 1 次)
| 字节偏移 | 长度 | 内容 | 说明 |
|----------|------|------|------|
| 0 | 1 | `0xFD` | 固定标识:开始图片传输 |
| 1 ~ 22 | 22 | 文件名 | UTF-8/ASCII 编码,不足 22 字节补 `0x00` |
| 23 | 1 | `(len >> 16) & 0xFF` | 图片总大小 - 高字节 |
| 24 | 1 | `(len >> 8) & 0xFF` | 图片总大小 - 中字节 |
| 25 | 1 | `len & 0xFF` | 图片总大小 - 低字节 |
> - 图片大小为 3 字节大端序,最大支持 16,777,215 字节(~16MB
> - 文件名**必须以 `.jpg` 结尾**,示例:`face_1708012345.jpg`
> - 文件名不能超过 22 字节(含扩展名),建议用 `face_时间戳.jpg` 格式
### 4.3 数据帧(循环发送直到传完)
| 字节偏移 | 长度 | 内容 | 说明 |
|----------|------|------|------|
| 0 | 1 | 包序号 | 从 0 递增溢出后回绕0~255 循环) |
| 1 | 1 | 结束标志 | `0x00` = 后续还有数据;`非0` = 最后一包 |
| 2 ~ N | 最大 **507** | 图片数据 | 实际负载 = 本包总长 - 2 |
> 每个数据帧总长 = 2帧头 + 图片数据 ≤ **509 字节**
### 4.4 传输示例
以一张 1521 字节的图片 `face_170801.jpg` 为例CHUNK_SIZE = 507
```
第 1 包前序帧26 字节):
FD 66 61 63 65 5F 31 37 30 38 30 31 2E 6A 70 67 00 ... 00 05 F1
│ └─────── "face_170801.jpg" + 补零 ──────────────┘ └── 1521 大端序
└─ 标识符 0xFD
[等待 50ms]
第 2 包数据帧509 字节 = 2 + 507:
00 00 [507 字节图片数据]
│ └─ 未结束
└─ 包序号 0
[等待 5ms]
第 3 包数据帧509 字节 = 2 + 507:
01 00 [507 字节图片数据]
│ └─ 未结束
└─ 包序号 1
[等待 5ms]
第 4 包数据帧最后一包509 字节 = 2 + 507:
02 01 [507 字节图片数据]
│ └─ 结束标志 = 1
└─ 包序号 2
```
设备端收到结束帧后,自动:写入 SPIFFS → 更新 NVS → 跳转到图片浏览界面显示新图片。
---
## 五、图片管理命令(管理特征 0x0B02
### 5.1 设置为当前表盘
| 字节偏移 | 长度 | 内容 | 说明 |
|----------|------|------|------|
| 0 ~ 22 | 23 | 文件名 | 与传输时使用的文件名一致,不足补 `0x00` |
| 23 | 1 | `0xFF` | 命令标识:设置为当前表盘 |
设备端收到后:更新 NVS 记录 → 跳转到图片浏览界面显示指定图片。
### 5.2 删除图片
| 字节偏移 | 长度 | 内容 | 说明 |
|----------|------|------|------|
| 0 ~ 22 | 23 | 文件名 | 要删除的文件名 |
| 23 | 1 | `0xF1` | 命令标识:删除该图片 |
设备端收到后从 SPIFFS 中物理删除该文件。
---
## 六、APP 端图片处理流程
APP 端在传输前**必须对图片进行预处理**,确保设备端能正确解码显示:
```
用户选图/拍照
裁剪为 360×360必须设备屏幕分辨率
保存为 .jpg 文件(文件名后缀必须是 .jpg
JPEG 压缩quality: 80~100
读取为 ArrayBuffer
BLE 分包传输
```
### 6.1 关键约束
| 约束项 | 要求 | 原因 |
|--------|------|------|
| 分辨率 | **裁剪**为 360×360 | 设备屏幕为 360×360 圆形 LCD |
| 格式 | JPEGBaseline | 设备端 `esp_jpeg` 仅支持 Baseline JPEG 解码 |
| 文件后缀 | `.jpg` | 文件后缀决定设备端解码方式 |
| 压缩质量 | 80~100 | quality < 70 360×360 屏幕上画质明显模糊 |
| 单张大小 | 建议 < 100KB | SPIFFS 总容量 2MB兼顾传输速度和存储 |
> **踩坑提醒**
> - 裁剪 ≠ 缩放。使用 `crop` 参数进行裁剪,不要使用 `compressedWidth/compressedHeight`(那是缩放,会变形)
> - 文件保存时后缀必须是 `.jpg`,如果用 `.png` 后缀,压缩工具会输出 PNG 格式而非 JPEG设备端无法解码
> - 不支持 Progressive JPEG仅支持 Baseline JPEG
---
## 七、APP 端实现参考代码
### 7.1 通过 UUID 匹配服务和特征
```javascript
// 16-bit UUID 在 BLE 协议栈中会被扩展为 128-bit 格式
// 例如 0x0B00 → "00000b00-0000-1000-8000-00805f9b34fb"
// Android 和 iOS 均返回 128-bit 格式,统一用小写匹配
function findByUuid16(list, uuid16) {
const target = `0000${uuid16.toString(16).padStart(4, '0')}-0000-1000-8000-00805f9b34fb`;
return list.find(item => item.uuid.toLowerCase() === target.toLowerCase());
}
// 使用示例
uni.getBLEDeviceServices({
deviceId: deviceId,
success(res) {
const imageService = findByUuid16(res.services, 0x0B00);
if (!imageService) { console.error('未找到图片服务'); return; }
uni.getBLEDeviceCharacteristics({
deviceId: deviceId,
serviceId: imageService.uuid,
success(charRes) {
const writeChar = findByUuid16(charRes.characteristics, 0x0B01);
const editChar = findByUuid16(charRes.characteristics, 0x0B02);
// writeChar.uuid → 用于图片传输
// editChar.uuid → 用于管理命令
}
});
}
});
```
### 7.2 BLE 写入封装
```javascript
// 必须使用 writeNoResponse传输速度更快
function bleWrite(deviceId, serviceId, characteristicId, buffer) {
return new Promise((resolve, reject) => {
uni.writeBLECharacteristicValue({
deviceId,
serviceId,
characteristicId,
value: buffer,
writeType: 'writeNoResponse', // 关键Write Without Response
success: resolve,
fail: reject
});
});
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
```
### 7.3 图片选择与预处理
```javascript
// 1. 选择图片并裁剪为 360×360
function chooseAndCropImage() {
return new Promise((resolve, reject) => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
crop: {
quality: 100, // 裁剪时不压缩
width: 360, // 裁剪宽度
height: 360, // 裁剪高度
resize: true
},
success: (res) => resolve(res.tempFilePaths[0]),
fail: reject
});
});
}
// 2. 保存为 .jpg 后缀文件(关键!决定 compressImage 输出格式)
function saveAsJpg(tempPath, targetDir) {
const fileName = `face_${Date.now()}.jpg`; // 必须 .jpg 后缀
// APP-PLUS 使用 plus.io 复制文件
// 小程序端使用 getFileSystemManager
// ... 平台相关实现
return { fileName, filePath };
}
// 3. JPEG 压缩(输入文件后缀必须是 .jpg
function compressToJpeg(srcPath) {
return new Promise((resolve, reject) => {
uni.compressImage({
src: srcPath, // 输入路径后缀必须是 .jpg
quality: 80, // 80~100 推荐,兼顾画质和体积
success: (res) => resolve(res.tempFilePath),
fail: reject
});
});
}
// 4. 读取文件为 ArrayBufferAPP-PLUS 平台)
function readImageAsArrayBuffer(filePath) {
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(filePath, (fileEntry) => {
fileEntry.file((file) => {
const reader = new FileReader();
reader.onloadend = (e) => {
if (e.target.result) {
resolve(e.target.result);
} else {
reject(new Error('读取文件失败'));
}
};
reader.readAsArrayBuffer(file);
});
}, reject);
});
}
```
### 7.4 图片分包传输
```javascript
/**
* 发送图片到设备
* @param {string} deviceId - BLE 设备 ID
* @param {string} serviceUuid - 图片服务 UUID (0x0B00 的 128-bit 形式)
* @param {string} writeUuid - 写入特征 UUID (0x0B01 的 128-bit 形式)
* @param {string} filename - 文件名(须 .jpg 后缀,最长 22 字节)
* @param {ArrayBuffer} imageData - JPEG 图片二进制数据
* @param {function} onProgress - 进度回调 (0~100)
*/
async function sendImage(deviceId, serviceUuid, writeUuid, filename, imageData, onProgress) {
const data = new Uint8Array(imageData);
const len = data.length;
// 1. 发送前序帧26 字节)
const header = new Uint8Array(26);
header[0] = 0xFD;
for (let i = 0; i < Math.min(filename.length, 22); i++) {
header[i + 1] = filename.charCodeAt(i);
}
header[23] = (len >> 16) & 0xFF;
header[24] = (len >> 8) & 0xFF;
header[25] = len & 0xFF;
await bleWrite(deviceId, serviceUuid, writeUuid, header.buffer);
await delay(50); // 等待设备端建立接收通道
// 2. 分包发送图片数据
const CHUNK_SIZE = 507; // (MTU-3) - 2字节帧头 = 509 - 2
let offset = 0;
let packetNo = 0;
while (offset < len) {
const remaining = len - offset;
const chunkLen = Math.min(CHUNK_SIZE, remaining);
const isEnd = (offset + chunkLen >= len) ? 0x01 : 0x00;
const packet = new Uint8Array(2 + chunkLen);
packet[0] = packetNo & 0xFF;
packet[1] = isEnd;
packet.set(data.slice(offset, offset + chunkLen), 2);
await bleWrite(deviceId, serviceUuid, writeUuid, packet.buffer);
await delay(5); // 每包间隔 5ms防止 Android BLE 队列溢出
offset += chunkLen;
packetNo++;
if (onProgress) {
onProgress(Math.floor(offset / len * 100));
}
}
}
```
### 7.5 发送管理命令
```javascript
// 设置为当前表盘
async function setCurrentFace(deviceId, serviceUuid, editUuid, filename) {
const cmd = new Uint8Array(24);
for (let i = 0; i < Math.min(filename.length, 23); i++) {
cmd[i] = filename.charCodeAt(i);
}
cmd[23] = 0xFF;
await bleWrite(deviceId, serviceUuid, editUuid, cmd.buffer);
}
// 删除图片
async function deleteImage(deviceId, serviceUuid, editUuid, filename) {
const cmd = new Uint8Array(24);
for (let i = 0; i < Math.min(filename.length, 23); i++) {
cmd[i] = filename.charCodeAt(i);
}
cmd[23] = 0xF1;
await bleWrite(deviceId, serviceUuid, editUuid, cmd.buffer);
}
```
### 7.6 完整传输流程示例
```javascript
async function uploadWatchFace(deviceId, serviceUuid, writeUuid) {
// 1. 选图并裁剪为 360×360
const tempPath = await chooseAndCropImage();
// 2. 保存为 .jpg 后缀(关键!)
const { fileName, filePath } = saveAsJpg(tempPath, imageDir);
// 3. JPEG 压缩
const jpegPath = await compressToJpeg(filePath);
// 4. 读取为 ArrayBuffer
const buffer = await readImageAsArrayBuffer(jpegPath);
console.log('JPEG 大小:', buffer.byteLength, '字节');
// 5. BLE 分包传输
await sendImage(deviceId, serviceUuid, writeUuid, fileName, buffer, (progress) => {
console.log('传输进度:', progress + '%');
});
console.log('传输完成,设备将自动显示新图片');
}
```
---
## 八、Android / iOS 兼容注意事项
### 8.1 BLE 差异汇总
| 差异项 | Android | iOS |
|--------|---------|-----|
| MTU 协商 | `setBLEMTU(512)` 通常成功 | 系统自动协商可能返回不同值需检查实际 MTU |
| 广播数据 | 厂商数据在 `advertisData` | 厂商数据可能在 `advertisData` 或其他字段中 |
| 设备 ID 格式 | MAC 地址格式 `D0:CF:13:03:BB:F2` | UUID 格式每次配对可能不同 |
| BLE 队列 | **队列有限,发送过快触发 10007 错误** | 队列较深但仍建议加延时 |
| writeNoResponse | 必须加 5ms+ 包间延时 | 可适当减少延时 |
| 蓝牙缓存 | 无明显缓存问题 | **系统缓存服务/特征列表,设备固件更新后可能读到旧数据** |
### 8.2 Android 特别注意
1. **BLE 队列溢出(错误码 10007**Android BLE 写入队列有限使用 `writeNoResponse` 时如果发送速度过快队列满后会返回错误码 10007。**必须在每包之间加 5ms 延时**。
2. **MTU 协商**部分 Android 设备可能协商到低于 512 MTU建议协商后读取实际 MTU 动态调整 CHUNK_SIZE = `实际MTU - 3 - 2`
3. **数据包截断**如果单包超过 `MTU - 3` 字节Android **不会报错**而是**静默截断**数据这会导致设备端数据不完整JPEG 解码报错错误码 6 = JDR_FMT1
### 8.3 iOS 特别注意
1. **蓝牙缓存**iOS 会缓存 BLE 设备的服务和特征信息如果设备固件更新了 UUID 或服务结构iOS 可能仍使用旧缓存解决方法关闭 iOS 蓝牙 重新打开 重连设备
2. **设备 ID 不固定**iOS 返回的 `deviceId` 是系统分配的 UUID**不是 MAC 地址**且取消配对后可能变化不要用 `deviceId` 做设备持久化标识
3. **MTU 自动协商**iOS 不支持手动设置 MTU`setBLEMTU` 可能不生效系统自动协商通常 iOS 12+ 协商到 185~512 之间
### 8.4 统一兼容建议
```javascript
// 连接后获取实际 MTU兼容 Android/iOS
let actualMtu = 512;
// Android: 手动协商
// #ifdef APP-PLUS
if (uni.getSystemInfoSync().platform === 'android') {
await new Promise((resolve) => {
uni.setBLEMTU({
deviceId,
mtu: 512,
success: (res) => { actualMtu = 512; resolve(); },
fail: () => { actualMtu = 23; resolve(); } // 协商失败用默认 MTU
});
});
}
// #endif
// 动态计算 CHUNK_SIZE
const CHUNK_SIZE = actualMtu - 3 - 2; // MTU - ATT头(3) - 帧头(2)
```
---
## 九、错误排查指南
| 现象 | 可能原因 | 解决方案 |
|------|----------|----------|
| 设备端 JPEG 解码失败错误码 6 | 数据包超过 MTU-3 被截断 | 确认 CHUNK_SIZE = MTU-3-2 = 507 |
| Android 报错 10007 | BLE 写入队列溢出 | 每包间隔加 `await delay(5)` |
| 设备收到的不是 JPEG | 文件后缀为 `.png` | 保存文件时后缀改为 `.jpg` |
| 图片模糊 | JPEG quality 过低 | 提高 quality 80~100 |
| 图片变形拉伸 | 使用了缩放而非裁剪 | 使用 `crop` 参数裁剪 360×360 |
| 传输完成但设备无反应 | 前序帧 `type` 不是 `0xFD` | 检查前序帧第 0 字节 |
| 找不到服务/特征 | UUID 匹配逻辑错误 | 16-bit UUID 需扩展为 128-bit 后比较 |
| iOS 发现旧的服务结构 | iOS 蓝牙缓存 | 关闭/重开 iOS 蓝牙后重连 |
| 传输中断无法恢复 | 无断点续传机制 | 需从头重新传输整张图片 |
---
## 十、完整时序图
```
APP 端 设备端 (ESP32-S3)
| |
|--- BLE 扫描(过滤厂商数据 "dzbj" | 广播中
| 或设备名前缀 "Airhub_"------------------->|
| |
|--- createBLEConnection --------------------->|
|<-- 连接成功 ----------------------------------|
| |
|--- setBLEMTU(512) [Android] ---------------->|
|<-- MTU 协商完成 -------------------------------|
| |
|--- getBLEDeviceServices -------------------->|
|<-- 服务列表(匹配 UUID 0x0B00----------------|
| |
|--- getBLEDeviceCharacteristics -------------->|
|<-- 特征列表0x0B01 写入 + 0x0B02 管理)--------|
| |
|=========== 图片预处理 ==========================|
| |
| 裁剪 360×360 → 保存 .jpg → JPEG 压缩 |
| → 读取为 ArrayBuffer |
| |
|=========== 传输图片 ============================|
| |
|--- writeNoResponse 0x0B01: |
| [0xFD + 文件名 + 大小] (26 字节) | 解析前序帧
| | → malloc 接收缓冲区
| [等待 50ms] | → fopen 创建文件
| |
|--- writeNoResponse 0x0B01: |
| [0 + 0x00 + 507字节数据] (≤509字节) | 数据帧 1
| [等待 5ms] |
|--- writeNoResponse 0x0B01: |
| [1 + 0x00 + 507字节数据] (≤509字节) | 数据帧 2
| [等待 5ms] |
|--- ... |
|--- writeNoResponse 0x0B01: |
| [N + 0x01 + 剩余数据] (≤509字节) | 末尾帧
| | → fwrite 写入 SPIFFS
| | → 更新 NVS 当前表盘
| | → 自动跳转图片浏览界面
| |
|=========== 管理命令 ============================|
| |
|--- writeNoResponse 0x0B02: |
| [文件名 + 0xFF] (24字节) | 设置为当前表盘
| | → 跳转图片浏览界面
| |
|--- writeNoResponse 0x0B02: |
| [文件名 + 0xF1] (24字节) | 删除指定图片
| |
|--- closeBLEConnection ---------------------->| → 自动恢复广播
| |
```
---
## 附录:协议参数速查
```
设备名称: Airhub_xx:xx:xx:xx:xx:xx动态后缀为BLE MAC
服务 UUID: 0x0B00 (00000b00-0000-1000-8000-00805f9b34fb)
写入特征 UUID: 0x0B01 (00000b01-0000-1000-8000-00805f9b34fb)
管理特征 UUID: 0x0B02 (00000b02-0000-1000-8000-00805f9b34fb)
MTU: 512
单包最大值: 509 字节 (MTU - 3)
CHUNK_SIZE: 507 字节 (509 - 2字节帧头)
前序帧标识: 0xFD
设置表盘命令: 0xFF
删除图片命令: 0xF1
图片格式: JPEG (Baseline)
图片分辨率: 360 × 360
写入方式: writeNoResponse
包间延时: 5ms (数据帧) / 50ms (前序帧后)
```