1. 从零开始:为什么小程序与ESP32的蓝牙通信这么“绕”?

如果你和我一样,刚开始接触ESP32的蓝牙配网,尤其是想用微信小程序来控制它,那你大概率会卡在第一步:数据怎么传?为什么我明明在JavaScript里定义了一个字符串,到了ESP32那边就变成了一堆乱码?为什么官方文档里动不动就是ArrayBuffer、Uint8Array这些看起来就头疼的东西?

别慌,这种感觉我太懂了。我刚上手的时候,也以为不就是发个Wi-Fi密码嘛,wx.writeBLECharacteristicValue 一把梭不就完事了?结果现实狠狠给了我一巴掌。蓝牙低功耗(BLE)通信,本质上传输的是二进制数据流,它不认识你JavaScript里优雅的字符串对象。这就好比你要给一个只懂摩斯电报的人发微信,你必须先把你的文字(字符串)转换成滴滴答答的点划信号(二进制),对方接收后再反向翻译回来。这个“翻译”的过程,就是我们在小程序开发里遇到的核心挑战:数据格式的编码与解码。

而ESP32的Blufi配网协议,就是一套定义好的“电报密码本”。它规定了什么样的指令(比如连接Wi-Fi)该用什么样的“滴滴答答”(特定的字节序列)来表示。我们小程序端的任务,就是严格按照这个密码本,把我们的配网信息(SSID、密码)转换成正确的二进制数据包,然后通过蓝牙发送出去。听起来简单,但魔鬼全在细节里。原始文章里给出了几个关键的转换函数,那是我们打通任督二脉的基础。接下来,我们就抛开那些让人犯困的理论,直接上手,把这些转换函数掰开了、揉碎了,看看它们到底是怎么工作的,以及在实际项目中你会遇到哪些坑。

2. 核心武器库:你必须掌握的四种数据转换“魔法”

原始文章里列出了四个函数,这简直就是我们小程序端与ESP32蓝牙通信的“四大护法”。光看代码可能有点抽象,我来给你打个比方,并配上详细的解读和踩坑记录。

2.1 ArrayBuffer转16进制字符串:你的“数据显微镜”

这个函数叫 ab2hex,它的作用是把一个二进制的 ArrayBuffer 对象,转换成一串我们人类能看懂的16进制字符串。这有什么用呢?调试,疯狂地调试! 当你从ESP32设备那里收到一包数据时,它最初就是一个 ArrayBuffer,你直接 console.log 出来是一堆 [object ArrayBuffer],啥也看不出来。这时候你就需要这个“显微镜”。

function ab2hex(buffer) {
  var hexArr = Array.prototype.map.call(
    new Uint8Array(buffer),
    function (bit) {
      return ('00' + bit.toString(16)).slice(-2)
    }
  )
  return hexArr.join('');
}

我来拆解一下这个“魔法”:

  1. new Uint8Array(buffer): 首先,我们创建一个 Uint8Array 视图,它像一把尺子,允许我们以字节(8位,0-255)为单位来“丈量” ArrayBuffer 这块原始内存。
  2. Array.prototype.map.call(...): 因为 Uint8Array 不是真正的数组,没有 map 方法,所以我们用这个技巧“借用”数组的 map 方法,对每一个字节进行处理。
  3. bit.toString(16): 把当前字节(一个0-255的数字)转换成16进制的字符串表示。比如数字 255 会变成 'ff'。
  4. ('00' + ...).slice(-2): 这是一个经典的补零操作。因为一个字节的16进制表示可能是1位(如 0x0a 的 'a')或2位(如 0xff 的 'ff')。为了保证输出格式统一为两位,我们先在前面拼接两个'0',然后始终取最后两位。这样 'a' 变成了 '0a','ff' 还是 'ff'。
  5. join(''): 最后把所有两位的16进制字符串拼接起来,得到像 "01000a1bff" 这样的字符串。

实测场景: 我在开发时,每次调用 wx.onBLECharacteristicValueChange 收到数据,第一件事就是 console.log('收到数据:', ab2hex(buffer))。这样我就能清晰地看到ESP32发回来的每一个字节是什么,对照Blufi协议文档,就能知道是配网成功、失败,还是需要我下一步操作。

2.2 ArrayBuffer转普通字符串:解码文本信息

这个函数 ab2str 用于一种特殊情况:当你确定ESP32发送过来的二进制数据,本身就是一段UTF-8编码的文本时(比如设备名称、自定义的文本信息),你可以用它直接转成字符串。

function ab2str(buf) {
  return String.fromCharCode.apply(null, new Uint8Array(buf));
}

注意坑点: 这个函数假设每个字节就是一个字符的ASCII码(或Latin-1)。对于纯英文数字没问题,但如果涉及中文,ESP32端需要用UTF-8编码,小程序端用这个简单函数解码就会乱码。对于复杂情况,更稳妥的做法是使用 TextDecoder API:

function ab2strUtf8(buf) {
  const decoder = new TextDecoder('utf-8');
  return decoder.decode(new Uint8Array(buf));
}

2.3 16进制字符串转ArrayBuffer:手动构造数据包

strhex2ab 这个函数是 ab2hex 的逆过程。它在你需要手动构造一个特定的、已知16进制序列的数据包时非常有用。比如,你想测试某个特定的Blufi控制指令,可以直接写出它的16进制形式,然后用这个函数转换成 ArrayBuffer 发送。

function strhex2ab(str) {
  var out = new ArrayBuffer(str.length/2); // 两个字符代表一个字节
  var u8a = new Uint8Array(out);
  for(var i = 0; i < str.length/2; i++){
    u8a[i] = parseInt(str.substr(2*i, 2), 16); // 每两位解析成一个16进制数
  }
  return out;
}

使用示例: 假设你知道断开Wi-Fi连接的指令是 0x0004(根据原始文章的 controllEnum),你可以这样发送:

let cmdBuffer = strhex2ab('0004');
// 然后通过 writeBLECharacteristicValue 发送 cmdBuffer

2.4 字符串转ArrayBuffer:发送文本数据的正道

这是最常用、也最容易出错的函数之一。我们的Wi-Fi密码、SSID都是字符串,最终都要通过这个 str2ab 函数(或它的变体)转换成二进制。原始文章给的函数 str2ab 使用了 Uint16Array,这通常用于UTF-16编码。但在BLE和ESP32的Blufi场景下,更常见的做法是使用UTF-8编码,并用 Uint8Array。

这里我强烈推荐你使用下面这个改进版:

function str2abUtf8(str) {
  const encoder = new TextEncoder(); // TextEncoder 默认使用 UTF-8
  return encoder.encode(str).buffer; // 返回 ArrayBuffer
}

TextEncoder 是现代浏览器和小程序环境都支持的API,它帮你完美处理了各种字符的UTF-8编码,省心又可靠。如果你坚持使用类原始文章的手动方法,那一定要确保和ESP32端的解码方式匹配,否则中文密码一定会发送失败。

3. 深入Blufi协议:组装属于你的配网指令

掌握了数据转换,我们就像有了砖块。现在,要照着Blufi协议这个“图纸”来盖房子(组装数据包)。原始文章里的 bleSendCMD 函数就是这个盖房子的核心工具。我们再来仔细分析一遍,并补充一些关键细节。

3.1 拆解 bleSendCMD:一个数据包是如何炼成的

这个函数接收四个参数:主命令(CMD)、子命令(subCMD)、帧控制(frameControl)和载荷(payload)。它的目标就是把这些信息打包成一个符合Blufi格式的 ArrayBuffer。

bleSendCMD(CMD, subCMD, frameControl, payload) {
  // 1. 计算包总长度:6字节头 + 载荷长度
  var ab = new ArrayBuffer(payload.length + 6);
  var u8array = new Uint8Array(ab);

  // 2. 组装第一字节:低2位是主命令,高6位是子命令(移位后)
  var LSB_Type = ((subCMD & 0x3f) << 2) | (CMD & 0x03);
  u8array[0] = LSB_Type;

  // 3. 填充剩余头部
  u8array[1] = frameControl; // 帧控制,如是否加密、是否需要应答
  u8array[2] = this.data.bleSendSequence; // 序列号,用于匹配请求与响应
  u8array[3] = payload.length; // 载荷长度

  // 4. 拷贝载荷数据
  for(let i = 0; i < payload.length; i++) {
    u8array[4 + i] = payload[i];
  }

  // 5. 序列号自增,为下一个包准备
  this.data.bleSendSequence++;

  // 6. 通过小程序API发送
  wx.writeBLECharacteristicValue({
    deviceId: this.data.deviceId,
    serviceId: this.data.deviceWrteServiceId,
    characteristicId: this.data.deviceWrteCharacteristics,
    value: ab, // 这里发送的就是我们组装好的 ArrayBuffer
  });
}

关键点解析:

  • 序列号(Sequence): 这个非常重要!Blufi协议是异步的,你发送一个设置密码的请求,ESP32可能会回复一个“收到”的ACK。你需要用这个序列号来确认哪个回复对应哪个请求。务必保证其唯一性和递增性,通常从0开始。
  • 帧控制(frameControl): 这是一个位字段,用来指示这个数据包的一些属性。比如,0x0 可能表示普通数据帧;0x1 可能表示该帧是分片的,后面还有;0x2 可能表示需要对方回复ACK。具体值需要查阅ESP-IDF中Blufi协议的详细定义。在简单配网中,发送Wi-Fi信息时通常设为 0x00 即可。
  • 载荷(Payload): 这就是我们的核心数据,比如Wi-Fi密码的字节序列。它必须是 Uint8Array 或类似视图,不能直接是字符串。

3.2 实战:发送Wi-Fi密码的完整流程

原始文章里发送密码的例子有点简略,我把它扩展成一个更健壮、更清晰的版本,并加入错误处理。

首先,我们需要定义好命令枚举,直接使用原始文章里提供的就很好:

// 放在你的 app.js 或单独的数据文件里
const dataEnum = {
  PACKAGE_VALUE: 0x01,
  SUBTYPE_STA_WIFI_SSID: 0x02,
  SUBTYPE_STA_WIFI_PASSWORD: 0x03,
  SUBTYPE_SOFTAP_WIFI_SSID: 0x04,
  SUBTYPE_SOFTAP_WIFI_PASSWORD: 0x05,
  // ... 其他子类型
};

const controllEnum = {
  PACKAGE_VALUE: 0x00,
  SUBTYPE_CONNECT_WIFI: 0x03,
  // ... 其他控制类型
};

然后,假设用户在一个输入框里输入了Wi-Fi密码,并点击了连接按钮:

// 在Page的 methods 中
async onConnectWifi() {
  const ssid = 'MyHomeWiFi'; // 实际应从输入框获取
  const password = 'MySecretPassword123'; // 实际应从输入框获取

  // 1. 发送SSID
  const ssidPayload = this.str2abUtf8(ssid); // 使用UTF-8编码转换
  this.bleSendCMD(
    dataEnum.PACKAGE_VALUE,
    dataEnum.SUBTYPE_STA_WIFI_SSID,
    0x00, // frameControl
    new Uint8Array(ssidPayload) // 确保传入的是Uint8Array
  );

  // 等待一小段时间,避免数据包拥塞(BLE MTU通常很小)
  await this.sleep(100);

  // 2. 发送密码
  const pwdPayload = this.str2abUtf8(password);
  this.bleSendCMD(
    dataEnum.PACKAGE_VALUE,
    dataEnum.SUBTYPE_STA_WIFI_PASSWORD,
    0x00,
    new Uint8Array(pwdPayload)
  );

  await this.sleep(100);

  // 3. 发送连接命令
  // 注意:连接命令是控制类型(PACKAGE_VALUE: 0x00),且没有payload
  this.bleSendCMD(
    controllEnum.PACKAGE_VALUE,
    controllEnum.SUBTYPE_CONNECT_WIFI,
    0x00,
    new Uint8Array(0) // 空载荷
  );

  wx.showToast({ title: '配网指令已发送', icon: 'success' });
},

// 一个简单的睡眠函数
sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

踩坑提醒:

  1. 顺序很重要: 通常需要先发送SSID,再发送密码,最后发送连接指令。ESP32的Blufi组件会按顺序缓存这些信息。
  2. 速率控制: BLE通道不能一下子灌入太多数据。在关键指令间添加少量延迟(如50-100ms)可以极大提高稳定性,避免丢包。
  3. 错误处理: wx.writeBLECharacteristicValue 是一个异步操作,它的 success 和 fail 回调一定要加上。在 fail 回调里,可以检查错误码,常见的有 10001(未连接设备)、10004(系统错误)等,并给用户相应的提示。

4. 双向通信:如何接收并解析ESP32的回复

配网不是单方面喊话。我们发了SSID、密码和连接命令后,ESP32会尝试连接,并给我们反馈结果。所以,监听并处理ESP32发来的数据同样关键。

4.1 建立数据监听

在成功连接蓝牙并发现服务/特征值后,你需要立即设置特征值的订阅(Notify/Indicate),并监听数据变化。

// 在连接蓝牙、发现特征值成功的回调里
wx.notifyBLECharacteristicValueChange({
  deviceId: deviceId,
  serviceId: serviceId,
  characteristicId: notifyCharId, // 用于接收数据的特征值ID
  state: true, // 启用订阅
  success: (res) => {
    console.log('订阅通知成功');
  }
});

// 监听特征值变化
wx.onBLECharacteristicValueChange((res) => {
  console.log('收到设备消息,特征值ID:', res.characteristicId);
  // 使用我们的“显微镜”查看原始数据
  const hexString = this.ab2hex(res.value);
  console.log('原始数据(Hex):', hexString);

  // 调用协议解析函数
  this.parseBlufiResponse(res.value);
});

4.2 解析Blufi响应数据

parseBlufiResponse 函数需要根据Blufi协议,反向解析我们收到的 ArrayBuffer。响应包的格式和发送包类似,也包含类型、序列号、载荷等。

parseBlufiResponse(buffer) {
  const u8arr = new Uint8Array(buffer);
  if (u8arr.length < 4) {
    console.error('数据包长度过短');
    return;
  }

  const firstByte = u8arr[0];
  const pkgType = firstByte & 0x03; // 取低2位,判断是数据包(1)还是控制包(0)等
  const subtype = (firstByte >> 2) & 0x3f; // 取高6位,子类型

  const sequence = u8arr[2]; // 序列号
  const dataLength = u8arr[3]; // 载荷长度
  const payload = u8arr.slice(4, 4 + dataLength); // 载荷数据

  console.log(`收到包: 类型=${pkgType}, 子类型=0x${subtype.toString(16)}, 序列号=${sequence}, 长度=${dataLength}`);

  // 根据子类型进行不同处理
  switch (subtype) {
    case dataEnum.SUBTYPE_ACK: // 应答包,对应原始文章的 controllEnum.SUBTYPE_ACK
      console.log(`设备已收到序列号为 ${payload[0]} 的包`);
      break;

    case dataEnum.SUBTYPE_WIFI_CONNECTION_STATE: // Wi-Fi连接状态
      // 载荷的第一个字节表示状态
      const state = payload[0];
      let msg = '';
      if (state === 0x00) msg = 'Wi-Fi连接成功!';
      else if (state === 0x01) msg = 'Wi-Fi连接失败';
      else if (state === 0x02) msg = 'Wi-Fi已断开';
      wx.showModal({ title: '连接状态', content: msg, showCancel: false });
      break;

    case dataEnum.SUBTYPE_ERROR: // 错误报告
      const errorCode = payload[0];
      console.error('设备报告错误,代码:', errorCode);
      wx.showToast({ title: `设备错误: ${errorCode}`, icon: 'none' });
      break;

    case dataEnum.SUBTYPE_CUSTOM_DATA: // 自定义数据(可用于扩展)
      const customStr = this.ab2strUtf8(payload.buffer);
      console.log('收到设备自定义消息:', customStr);
      break;

    default:
      console.warn('收到未处理的子类型:', subtype);
  }
}

通过这样的双向解析,你的小程序就不再是“盲人摸象”,而是能清晰地知道ESP32每一步在干什么,是连接成功了还是密码错了,用户体验会好很多。

5. 避坑指南与性能优化:让配网稳如泰山

搞定了基本流程,最后分享几个我踩过坑才总结出来的经验,能帮你把配网成功率从“看运气”提升到“很稳定”。

坑一:MTU大小限制 蓝牙BLE有一个参数叫MTU(最大传输单元),它决定了你一次性能发送多少字节。小程序初始连接时MTU可能只有23字节,去掉协议头,留给有效载荷的就更少了。如果你要发送一个很长的Wi-Fi密码(比如20个字符的WPA2密码),可能会超出限制。 解决方案: 在连接蓝牙后,尝试协商一个更大的MTU。

wx.setBLEMTU({
  deviceId: deviceId,
  mtu: 512, // 尝试设置为512,实际生效值以success回调为准
  success: (res) => {
    console.log('设置MTU成功,实际MTU:', res.mtu);
  }
});

即使设置了,在组包时也要做好检查,如果载荷过长,可能需要实现数据分片机制(利用frameControl中的分片位),这对于发送证书等大数据量场景是必须的。

坑二:蓝牙连接状态管理 小程序退到后台,蓝牙连接可能会断开。用户操作不当也可能断开。你的代码必须能处理这些情况。 解决方案: 监听 wx.onBLEConnectionStateChange 事件。

wx.onBLEConnectionStateChange((res) => {
  console.log(`设备 ${res.deviceId} 连接状态变化: ${res.connected}`);
  if (!res.connected) {
    wx.showToast({ title: '设备已断开', icon: 'error' });
    // 清理资源,如关闭定时器,重置状态变量
    this.data.bleSendSequence = 0;
  }
});

坑三:序列号重置 序列号应该在每次重新建立蓝牙连接后重置为0。如果你在断开重连后继续使用之前的序列号,可能会导致设备端匹配混乱。

性能优化建议:

  1. 缓存特征值UUID: 找到读写特征值后,把它们的 deviceId, serviceId, characteristicId 缓存起来,后续所有读写操作都直接用,避免重复查找。
  2. 合并短指令: 对于连续的、小的配置指令(比如设置认证模式、信道),如果协议允许,可以考虑组合在一个数据包里发送,减少通信回合。
  3. 友好的用户界面: 在发送配网指令期间,显示一个加载动画,并禁用相关按钮,防止用户重复点击。在收到成功或失败回调后,再更新界面状态。

把这些细节都处理好之后,你会发现,ESP32通过蓝牙配合微信小程序进行配网,从一个令人头疼的技术难题,变成了一套稳定、可控、用户体验良好的标准流程。剩下的,就是发挥你的创意,用这个技术去打造更酷的物联网产品了。

更多推荐