595 lines
22 KiB
Markdown
595 lines
22 KiB
Markdown
# BLE 蓝牙传图接口说明
|
||
|
||
> 本文档供 APP 开发工程师参考,描述手机端与 ESP32-S3 设备端的 BLE 图片传输协议。
|
||
> 设备固件:ESP-IDF 5.4 + Bluedroid BLE GATT Server
|
||
> 适用平台:Android + iOS(Flutter 跨平台框架)
|
||
> 更新日期:2026-02-27
|
||
|
||
---
|
||
|
||
## 一、设备信息
|
||
|
||
| 项目 | 值 |
|
||
|------|-----|
|
||
| 设备名称 | `Airhub_xx:xx:xx:xx:xx:xx`(动态名称,后缀为 BLE MAC 地址) |
|
||
| 广播类型 | ADV_IND(可连接、可扫描) |
|
||
| 广播间隔 | 20ms |
|
||
| MTU | 512 字节(设备端已设置,APP 端连接后必须协商) |
|
||
| 屏幕分辨率 | 360×360 圆形 LCD |
|
||
| 图片格式 | JPEG(Baseline) |
|
||
| 存储空间 | 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 Response(writeNoResponse)** 以提升传输速度
|
||
> - 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 = 512(setBLEMTU) ← 必须!否则每包只能发 20 字节
|
||
5. 发现服务,通过 UUID 匹配 0x0B00
|
||
6. 发现特征,通过 UUID 匹配 0x0B01(写入)和 0x0B02(管理)
|
||
7. 就绪,可以传输图片或发送管理命令
|
||
```
|
||
|
||
---
|
||
|
||
## 四、图片传输协议(写入特征 0x0B01)
|
||
|
||
### 4.1 关键参数
|
||
|
||
| 参数 | 值 | 说明 |
|
||
|------|-----|------|
|
||
| MTU | 512 | 设备端设置,APP 端连接后协商 |
|
||
| ATT 有效载荷 | **509** | MTU - 3(ATT 协议头占 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 |
|
||
| 格式 | JPEG(Baseline) | 设备端 `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. 读取文件为 ArrayBuffer(APP-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 (前序帧后)
|
||
```
|