22 KiB
22 KiB
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 匹配服务和特征
// 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 写入封装
// 必须使用 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 图片选择与预处理
// 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 图片分包传输
/**
* 发送图片到设备
* @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 发送管理命令
// 设置为当前表盘
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 完整传输流程示例
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 特别注意
- BLE 队列溢出(错误码 10007):Android 的 BLE 写入队列有限,使用
writeNoResponse时如果发送速度过快,队列满后会返回错误码 10007。必须在每包之间加 5ms 延时。 - MTU 协商:部分 Android 设备可能协商到低于 512 的 MTU。建议协商后读取实际 MTU 值,动态调整 CHUNK_SIZE =
实际MTU - 3 - 2。 - 数据包截断:如果单包超过
MTU - 3字节,Android 不会报错,而是静默截断数据。这会导致设备端数据不完整,JPEG 解码报错(错误码 6 = JDR_FMT1)。
8.3 iOS 特别注意
- 蓝牙缓存:iOS 会缓存 BLE 设备的服务和特征信息。如果设备固件更新了 UUID 或服务结构,iOS 可能仍使用旧缓存。解决方法:关闭 iOS 蓝牙 → 重新打开 → 重连设备。
- 设备 ID 不固定:iOS 返回的
deviceId是系统分配的 UUID,不是 MAC 地址,且取消配对后可能变化。不要用deviceId做设备持久化标识。 - MTU 自动协商:iOS 不支持手动设置 MTU(
setBLEMTU可能不生效),系统自动协商。通常 iOS 12+ 协商到 185~512 之间。
8.4 统一兼容建议
// 连接后获取实际 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 (前序帧后)