1. 为什么要在平板上调试 ESP32
第一次看到 PyBLE 这个项目的时候,我正在阳台上用平板看文档,手边只有一块 ESP32 开发板和一根数据线。当时脑子里冒出的第一个念头就是:如果能在平板上直接写 MicroPython 代码、直接跑、直接看输出,那该多省事。PyBLE 恰好就是干这个的——它把代码编辑器和 REPL 终端通过 BLE 搬到了移动设备上,让你彻底摆脱电脑和串口线的束缚。
这个项目的核心价值在于把调试入口从桌面端迁移到了移动端。传统玩 ESP32 的流程是:电脑装 Arduino IDE 或者 Thonny,插 USB 线,选串口,烧录固件,打开串口监视器。这套流程在工位上没问题,但一旦你想在沙发、床上、户外甚至车里快速验证一段逻辑,掏出电脑就变得很笨重。PyBLE 的思路是:ESP32 端跑一个 BLE 串口服务,平板端跑一个 Web 或原生 App 作为编辑器,两边通过 BLE 的 GATT 通道收发数据。你只需要给 ESP32 供上电,平板上打开 PyBLE,连上蓝牙,就能像在电脑上一样敲代码、执行、看回显。
适合看这篇内容的人有三类。第一类是刚接触 ESP32 和 MicroPython 的初学者,手里可能只有一块板子和一个平板,想用最低的成本跑通第一个脚本。第二类是经常做现场调试的嵌入式从业者,设备装在机柜里或者移动平台上,拉线不方便,需要无线调试手段。第三类是对 BLE 协议栈和 MicroPython 生态感兴趣的开发者,想看看一个轻量级 IDE 是怎么通过 GATT 把文件传输和终端交互做起来的。不管你属于哪一类,下面这些内容都是从实际踩坑里总结出来的,不是照本宣科的说明书。
需要提前说明的是,PyBLE 本身是一个开源项目,它的定位是“够用就好”的移动端调试工具,不是要替代桌面 IDE。它的优势是便携和低门槛,劣势是受限于 BLE 的带宽和移动端的输入体验。理解这个定位,你才不会对它有不切实际的期待。
2. PyBLE 的整体设计与技术选型拆解
2.1 为什么是 BLE 而不是 Wi-Fi 或串口
ESP32 支持 Wi-Fi、经典蓝牙、BLE 三种主要通信方式,PyBLE 选择 BLE 作为传输层,这个决定背后有几层考量。
Wi-Fi 的带宽确实比 BLE 大得多,理论上传文件更快。但 Wi-Fi 的问题在于配网复杂度和功耗。你要么让 ESP32 做 AP,平板连上去,要么让两者连同一个路由器。前者需要平板切换网络,后者需要知道路由器密码并且现场有可用网络。对于“掏出板子就想调”的场景,这两步都是摩擦。BLE 不需要配网,不需要路由器,上电即可被发现,连接过程在平板上点两下就完成。
串口(UART)是最传统的调试方式,稳定、带宽足够、工具链成熟。但它必须物理连线。USB 线一插,板子就被拴住了。对于装在设备内部的 ESP32,或者需要移动测试的场景,串口线是最大的束缚。BLE 把这条线变成了无线通道,代价是带宽下降和延迟增加,但对于 MicroPython 的 REPL 交互和中小规模脚本传输,这个代价完全可以接受。
从协议层面看,BLE 的 GATT 模型天然适合做这种“客户端-服务端”的数据通道。ESP32 作为 GATT Server,暴露两个特征值:一个用于接收平板发来的数据(写特征),一个用于向平板发送数据(通知特征)。平板作为 GATT Client,往写特征里塞代码或命令,从通知特征里收执行结果。这个模型简单、对称、容易实现,不需要额外的协议栈。
注意:BLE 的 MTU 默认是 23 字节,实际可用载荷更少。传输稍大的脚本时必须做分包和重组,这是 PyBLE 实现里最关键的细节之一,后面会展开讲。
2.2 MicroPython 在其中的角色
PyBLE 的 ESP32 端固件是基于 MicroPython 的。为什么不用 Arduino 框架或者 ESP-IDF?因为 MicroPython 提供了交互式 REPL,这是整个项目体验的基石。
在 Arduino 或 ESP-IDF 里,你要改一行代码,必须重新编译、烧录、复位,整个循环至少几十秒。MicroPython 的 REPL 允许你逐行输入、立即执行、马上看到结果。这种“所见即所得”的交互方式,配合 BLE 的无线通道,才构成了“平板上调试”的完整体验。如果底层是编译型框架,BLE 通道只能用来传固件,那就变成了无线烧录器,而不是调试 IDE。
MicroPython 还自带文件系统(通常是 LittleFS 或 FAT),你可以把脚本存成.py文件,也可以直接在 REPL 里执行。PyBLE 利用这一点,把平板端编辑的代码通过 BLE 传到 ESP32 的文件系统,或者直接送进 REPL 执行。两种模式各有用途:文件模式适合保存和复用,REPL 模式适合快速验证。
另外,MicroPython 的bluetooth模块封装了 BLE 协议栈的底层操作,让 ESP32 端的 BLE 服务实现变得非常简洁。你不需要直接操作 NimBLE 或 Bluedroid 的 C API,用 Python 就能定义 GATT 服务和特征值。这大大降低了项目的维护门槛,也让更多开发者能读懂和修改代码。
2.3 平板端为什么选 Web 而不是原生 App
PyBLE 的平板端实现方式在不同版本里有差异,但主流思路是基于 Web Bluetooth API 的浏览器方案。这个选择同样有明确的取舍。
原生 App 的优势是性能好、能访问更多系统能力、离线可用。但代价是你要为 Android 和 iOS 分别开发,还要处理应用商店审核、签名、分发等一系列问题。对于一个开源调试工具来说,这个维护成本太高。Web Bluetooth API 的优势是跨平台——只要浏览器支持,Android 平板、iPad、甚至桌面 Chrome 都能用同一套代码。分发也简单,打开一个网页就行,不需要安装。
Web Bluetooth 的局限也很明显。iOS 上的 Safari 对 Web Bluetooth 的支持一直不完整,所以 iPad 用户可能会遇到兼容性问题。Android 上的 Chrome 支持较好,但不同厂商的浏览器实现有差异。另外,Web Bluetooth 要求页面必须通过 HTTPS 加载(localhost 除外),这对本地部署提出了一定要求。
实操心得:如果你主要在 Android 平板上用,Chrome 或 Edge 的体验最稳。如果手头只有 iPad,建议先确认系统版本和浏览器版本,必要时考虑用支持 Web Bluetooth 的第三方浏览器。
2.4 整体数据流与模块划分
把上面几层串起来,PyBLE 的完整数据流是这样的:
- ESP32 上电,运行 MicroPython 固件,启动 BLE GATT Server,广播自定义服务 UUID。
- 平板打开 PyBLE 页面,调用 Web Bluetooth API 扫描设备,根据 UUID 过滤出目标 ESP32。
- 用户点击连接,平板作为 GATT Client 订阅通知特征,同时获取写特征的句柄。
- 用户在编辑器里写代码,点击“运行”,平板把代码按 MTU 分包,依次写入写特征。
- ESP32 端收到完整数据后,送入 REPL 执行,或者写入文件系统。
- 执行结果(包括 print 输出和异常信息)通过通知特征回传,平板实时显示在终端区域。
这个流程里,分包重组和流控是两个最容易出问题的环节。BLE 的写入操作有速率限制,如果平板发得太快,ESP32 端来不及处理,数据就会丢。PyBLE 通常会在每次写入后等待一个确认,或者根据 ESP32 的回传信号决定是否继续发送。这个机制在传大文件时尤其重要。
3. ESP32 端固件与 BLE 服务实现细节
3.1 MicroPython 固件烧录与基础配置
在开始之前,你需要确保 ESP32 上跑的是支持 BLE 的 MicroPython 固件。不是所有 MicroPython 版本都默认编译了bluetooth模块,尤其是针对 ESP32 的官方构建,需要确认固件版本。
烧录步骤本身不复杂,但有几个细节容易翻车。首先,ESP32 的烧录需要进入下载模式,通常是按住 BOOT 键再按一下 EN 键,然后松开。有些开发板标注的是 IO0 和 RST,逻辑是一样的。其次,烧录工具可以用 esptool,命令大致如下:
esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 460800 write_flash -z 0x1000 esp32-bluetooth-firmware.bin这里的0x1000是 ESP32 的固件起始地址,不同芯片型号可能不同,ESP32-S3 和 ESP32-C3 的地址有差异,烧录前务必确认。波特率 460800 是常用值,如果线材质量差或者 USB 口供电不稳,可以降到 115200。
烧录完成后,用串口工具连上去,应该能看到 MicroPython 的 REPL 提示符>>>。这时候输入import bluetooth,如果没有报错,说明 BLE 模块可用。如果报ImportError,说明固件没编译进去,需要换一个带 BLE 的构建版本。
注意:ESP32 的 BLE 和 Wi-Fi 共用射频前端,同时开启时会有资源竞争。PyBLE 场景下通常只用 BLE,建议在代码里不要同时初始化 Wi-Fi,否则可能出现连接不稳定或者功耗异常。
3.2 GATT 服务与特征值定义
ESP32 端的 BLE 服务是整个 PyBLE 的核心。它需要定义两个特征值:一个可写,用于接收平板发来的数据;一个支持通知,用于向平板推送数据。下面是一个基于 MicroPythonbluetooth模块的简化实现框架:
import bluetooth from ble_advertising import advertising_payload _IRQ_CENTRAL_CONNECT = 1 _IRQ_CENTRAL_DISCONNECT = 2 _IRQ_GATTS_WRITE = 3 class BLEUART: def __init__(self, name="PyBLE"): self._ble = bluetooth.BLE() self._ble.active(True) self._ble.irq(self._irq) self._connections = set() self._rx_buffer = bytearray() # 定义服务 UUID 和特征 UUID UART_UUID = bluetooth.UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") TX_UUID = bluetooth.UUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E") RX_UUID = bluetooth.UUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E") UART_SERVICE = ( UART_UUID, ( (TX_UUID, bluetooth.FLAG_NOTIFY), (RX_UUID, bluetooth.FLAG_WRITE | bluetooth.FLAG_WRITE_NO_RESPONSE), ), ) ((self._tx_handle, self._rx_handle),) = self._ble.gatts_register_services((UART_SERVICE,)) self._advertise()这段代码里,TX_UUID对应通知特征,RX_UUID对应写特征。UUID 用的是 Nordic UART Service 的经典定义,这个 UUID 在很多 BLE 串口工具里是通用的,方便调试。FLAG_WRITE_NO_RESPONSE允许平板在不等待确认的情况下连续写入,提高吞吐量,但代价是可靠性下降。实际项目中,PyBLE 可能会根据场景选择是否启用这个标志。
_irq方法是事件回调,处理连接、断开和写入事件。当平板往 RX 特征写入数据时,_IRQ_GATTS_WRITE事件触发,你可以从self._ble.gatts_read(self._rx_handle)读出数据,追加到_rx_buffer,然后检查是否收到完整指令。
3.3 数据分包与重组逻辑
BLE 的 ATT MTU 决定了单次写入的最大字节数。默认 MTU 是 23,减去 3 字节的 ATT 头,实际可用 20 字节。虽然可以通过 MTU 协商提高到 247 甚至更大,但为了兼容性,PyBLE 通常按 20 字节左右分包。
分包本身不难,难的是如何判断一帧数据结束。常见方案有三种:固定长度、长度前缀、结束符。PyBLE 这类工具通常用换行符\n作为结束标志,因为 REPL 本身就是按行执行的。平板端每发完一行就发一个\n,ESP32 端在_rx_buffer里查找\n,找到就切出一行送入 REPL。
这个方案简单有效,但有个坑:如果代码里本身包含换行,比如多行函数定义,就需要额外处理。PyBLE 的做法通常是进入“粘贴模式”,用特殊标记包裹多行代码,ESP32 端识别到标记后进入缓冲模式,直到收到结束标记再整体执行。MicroPython 的 REPL 原生支持粘贴模式,快捷键是Ctrl-E进入,Ctrl-D执行。PyBLE 在 BLE 通道上模拟了这个流程。
def _process_buffer(self): while b"\n" in self._rx_buffer: line, _, self._rx_buffer = self._rx_buffer.partition(b"\n") # 送入 REPL 执行 try: exec(line.decode(), globals()) except Exception as e: self._send(str(e).encode())上面这段是简化示意,实际实现要考虑 REPL 的上下文、异常回溯、以及输出重定向。MicroPython 的sys.stdout可以被重定向到一个自定义对象,把 print 的内容通过 BLE 通知发出去。这是 PyBLE 能实时显示输出的关键。
实操心得:分包大小不要死守 20 字节。先尝试 MTU 协商,如果平板和 ESP32 都支持更大 MTU,可以提高到 100 以上,传输效率会明显改善。但要注意,某些 Android 设备的 BLE 栈对 MTU 协商支持不好,协商失败后要能回退到默认值。
3.4 输出重定向与 REPL 交互
MicroPython 的 REPL 默认从 UART 读写。要让 REPL 走 BLE,需要把sys.stdin和sys.stdout替换成 BLE 通道的封装对象。这个操作在 MicroPython 里是可行的,但需要小心处理,因为 REPL 本身是 C 层实现的,Python 层的重定向只能覆盖print和input,不能完全接管底层 REPL。
PyBLE 的常见做法是不替换原生 REPL,而是自己实现一个轻量级的命令执行循环。平板发来的代码通过exec()执行,输出通过重定向的sys.stdout捕获并回传。这样虽然失去了原生 REPL 的一些特性(比如自动补全、历史记录),但换来了完全可控的数据通道。
import sys class BLEStdout: def __init__(self, ble_uart): self._ble_uart = ble_uart def write(self, data): self._ble_uart.send(data.encode() if isinstance(data, str) else data) def read(self, n): return b"" sys.stdout = BLEStdout(ble_uart)这段代码把sys.stdout指向 BLE 通道,之后所有print都会通过 BLE 发出去。注意read方法返回空,因为输入不走 stdout。输入通道由 BLE 的写特征单独处理。
这个方案有个副作用:如果代码执行时间过长,或者进入死循环,平板端会一直等不到输出,看起来像卡死。PyBLE 通常会在平板端加一个超时机制,超过一定时间没有收到数据就提示用户,并允许发送中断信号(比如Ctrl-C的字节码)来打断 ESP32 端的执行。
4. 平板端连接与实操全流程
4.1 环境准备与浏览器兼容性确认
平板端不需要安装任何 App,但需要满足几个条件。第一,浏览器必须支持 Web Bluetooth API。Android 上 Chrome 从 56 版本开始支持,Edge 也支持。iOS 上 Safari 的支持一直不完整,截至我写这篇内容时,iPadOS 上的 Safari 仍然没有开放 Web Bluetooth,所以 iPad 用户需要另想办法,比如用支持该 API 的第三方浏览器,或者改用 Android 平板。
第二,页面必须通过 HTTPS 加载,或者运行在 localhost。如果你是把 PyBLE 部署在本地,可以用简单的 HTTP 服务器加自签名证书,或者直接用localhost访问。如果是从网上打开,确保地址是https://开头。
第三,平板的蓝牙必须打开,并且浏览器有蓝牙权限。Android 上首次调用 Web Bluetooth 时会弹出权限请求,允许即可。如果之前拒绝过,需要在系统设置里手动授予。
注意:部分 Android 平板在省电模式下会限制后台蓝牙扫描,导致搜不到设备。调试时建议关闭省电模式,或者把浏览器加入白名单。
4.2 扫描、连接与 MTU 协商
打开 PyBLE 页面后,第一步是扫描设备。Web Bluetooth 的requestDevice方法会弹出系统级的设备选择器,你可以根据名称或服务 UUID 过滤。PyBLE 的 ESP32 端通常会广播名称“PyBLE”或者包含特定 UUID,在过滤器里填上就能快速定位。
const device = await navigator.bluetooth.requestDevice({ filters: [{ name: 'PyBLE' }], optionalServices: ['6e400001-b5a3-f393-e0a9-e50e24dcca9e'] }); const server = await device.gatt.connect(); const service = await server.getPrimaryService('6e400001-b5a3-f393-e0a9-e50e24dcca9e'); const txChar = await service.getCharacteristic('6e400003-b5a3-f393-e0a9-e50e24dcca9e'); const rxChar = await service.getCharacteristic('6e400002-b5a3-f393-e0a9-e50e24dcca9e'); await txChar.startNotifications(); txChar.addEventListener('characteristicvaluechanged', handleNotification);这段代码完成了连接、获取服务和特征、订阅通知的全过程。optionalServices里必须列出你要访问的服务 UUID,否则getPrimaryService会报权限错误。这是 Web Bluetooth 的安全机制,防止网页随意访问未声明的服务。
MTU 协商在 Web Bluetooth 里不是直接暴露的 API,浏览器会自动处理。你可以在连接后尝试写入一个较大的数据包,如果失败就减小包大小。实测下来,Android Chrome 通常能协商到 185 或 247 字节的 MTU,但不同设备差异很大。
4.3 代码编辑与发送执行
PyBLE 的编辑器通常是一个简单的文本区域,支持基本的语法高亮(取决于实现)。你可以直接在里面写 MicroPython 代码,然后点击“运行”按钮。发送逻辑大致如下:
async function sendCode(code) { const encoder = new TextEncoder(); const data = encoder.encode(code + '\n'); const chunkSize = 20; // 根据 MTU 调整 for (let i = 0; i < data.length; i += chunkSize) { const chunk = data.slice(i, i + chunkSize); await rxChar.writeValue(chunk); } }这里每次写入后await等待完成,确保不会因为发送过快而丢包。如果 ESP32 端支持WRITE_NO_RESPONSE,可以去掉await提高速度,但可靠性会下降。对于调试场景,我建议保留await,稳定优先。
发送多行代码时,要注意 REPL 的粘贴模式。如果直接逐行发送,REPL 会在每行结束后立即执行,导致函数定义不完整就报错。正确做法是先发送Ctrl-E(字节0x05)进入粘贴模式,然后发送完整代码,最后发送Ctrl-D(字节0x04)执行。PyBLE 的编辑器通常会帮你处理这个流程。
4.4 终端输出查看与交互
终端区域显示 ESP32 回传的数据。这些数据通过通知特征到达,在handleNotification回调里解析:
function handleNotification(event) { const value = event.target.value; const decoder = new TextDecoder(); const text = decoder.decode(value); terminalDiv.textContent += text; terminalDiv.scrollTop = terminalDiv.scrollHeight; }输出可能是print的内容,也可能是异常回溯。MicroPython 的异常信息比较详细,包括文件名、行号和错误类型,这些都会通过 stdout 回传。如果代码执行时间较长,输出会分批到达,终端区域要能实时追加并自动滚动到底部。
交互方面,除了运行代码,PyBLE 通常还支持发送单行命令。比如你在终端输入print('hello')然后回车,平板会把这一行通过 BLE 发过去执行。这相当于一个简化的 REPL,适合快速测试。
实操心得:终端区域建议加一个“清空”按钮。调试过程中输出会越积越多,不清空的话页面会变卡。另外,如果输出包含大量数据,考虑在平板端做限流,比如每秒最多渲染 10 次,避免 UI 线程被阻塞。
5. 常见问题与避坑指南
5.1 搜不到设备或连接失败
这是最常见的问题,原因通常有几个。第一,ESP32 没有在广播。检查代码里是否调用了_advertise(),以及广播间隔是否合理。第二,平板蓝牙没开或者权限被拒。去系统设置里确认。第三,设备已经被其他客户端连接。BLE 的 GATT Server 通常只允许一个 Client 连接,如果之前连过没断开,需要先断开旧连接。第四,UUID 过滤写错了。用通用扫描工具先确认设备广播的 UUID,再填到过滤器里。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 扫描列表为空 | ESP32 未广播 | 用手机蓝牙设置页看能否搜到 |
| 搜到但连不上 | 已被占用 | 重启 ESP32,断开其他客户端 |
| 连接后立即断开 | 服务 UUID 不匹配 | 核对代码和过滤器的 UUID |
| 连接超时 | 信号弱或干扰 | 靠近设备,远离 Wi-Fi 路由器 |
5.2 数据发送后无响应
平板发了代码,但终端没有任何输出。首先检查 ESP32 端是否真的收到了数据。可以在_IRQ_GATTS_WRITE回调里加一个 LED 翻转,直观判断。如果 LED 没反应,说明数据没到,可能是分包大小超过了 MTU,或者写特征没有正确订阅。
如果 ESP32 收到了但没执行,检查exec()的上下文。MicroPython 的exec默认在当前全局命名空间执行,如果代码里引用了未导入的模块,会抛NameError。另外,如果代码里有while True死循环,REPL 会被阻塞,后续数据无法处理。这种情况下需要发送中断信号,或者复位 ESP32。
5.3 输出乱码或截断
乱码通常是编码问题。MicroPython 默认用 UTF-8,平板端解码也要用 UTF-8。如果用了TextDecoder但没指定编码,某些浏览器可能默认用 Latin-1,导致中文乱码。显式指定new TextDecoder('utf-8')可以解决。
截断则是分包问题。如果 ESP32 端在_rx_buffer里没找到完整的\n就丢弃了数据,或者通知特征的单次发送超过了 MTU,输出就会被截断。解决方法是确保发送端按 MTU 分包,接收端做缓冲重组。
5.4 传输大文件时卡死
BLE 的带宽有限,实测下来稳定传输速率大概在 2-5 KB/s。传一个 10 KB 的脚本需要几秒钟,如果中间没有流控,很容易卡死。PyBLE 的改进方向是加入确认机制:平板每发一包,等 ESP32 回一个 ACK,再发下一包。这会降低速度,但能保证可靠性。
另一个坑是浏览器的内存限制。如果一次性把整个大文件读进内存再分包,平板可能会卡顿。建议用流式读取,边读边发。
注意:不要试图通过 BLE 传输固件或大体积资源文件。BLE 的定位是调试通道,不是文件传输通道。大文件用 USB 或者 Wi-Fi 更合适。
5.5 平板端 UI 卡顿
Web Bluetooth 的回调是在主线程执行的,如果通知频率很高,UI 会卡。解决办法是把数据处理放到 Web Worker 里,或者用requestAnimationFrame做节流。另外,终端区域不要用innerHTML追加,用textContent或者appendChild性能更好。
如果编辑器内容很长,语法高亮也会拖慢性能。PyBLE 的编辑器通常不做实时高亮,或者只对可见区域高亮,这是合理的取舍。
6. 我对这个项目的实际体会
用平板通过 BLE 调试 ESP32 这件事,刚开始觉得新鲜,用久了会发现它真正解决的是“快速验证”这个需求。我现在的习惯是:复杂的逻辑还是在电脑上写,写完通过 USB 烧录;但临时改个参数、试个传感器读数、验证一段小算法,直接掏平板连 BLE 就跑,省去了开电脑、插线、选串口的整套流程。
PyBLE 这类项目的价值不在于功能有多强大,而在于它把调试的门槛降到了“有板子有平板就行”。对于教学场景尤其友好,学生不用装一堆驱动和 IDE,打开浏览器就能看到代码在真实硬件上跑起来。这种即时反馈对初学者的激励作用,比任何教程都管用。
当然,它也有明显的边界。BLE 的带宽决定了它不适合传大文件,Web Bluetooth 的兼容性决定了它在 iOS 上体验受限,MicroPython 的性能决定了它不适合跑重负载任务。把这些边界搞清楚,在合适的场景用它,才能发挥最大价值。
最后分享一个小技巧:如果你经常用 PyBLE,可以在 ESP32 的boot.py里加上自动启动 BLE 服务的代码,这样每次上电就自动广播,平板直接连就行,不用再手动运行脚本。另外,给 ESP32 配一个带开关的电池盒,调试时不用一直插着线,移动性会更好。