# 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 (前序帧后) ```