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

22 KiB
Raw Permalink Blame History

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 dzbj0x64 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 格式,例如 0x0B0000000b00-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 匹配服务和特征

// 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. 读取文件为 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 图片分包传输

/**
 * 发送图片到设备
 * @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 特别注意

  1. BLE 队列溢出(错误码 10007Android 的 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 不支持手动设置 MTUsetBLEMTU 可能不生效),系统自动协商。通常 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 (前序帧后)