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