Puppeteer BluetoothEmulation 接口详解:page.bluetooth 模拟蓝牙适配器与外设的完整指南
本文基于 Puppeteer 的 BluetoothEmulation 接口文档展开,系统讲解如何通过 page.bluetooth 模拟蓝牙适配器状态、注入预连接外设,并结合 CDP 与 WebDriver BiDi 两套底层实现、以及仓库内的端到端测试,说明其调用链路与使用限制。读完本文,你将能够理解 emulateAdapter、simulatePreconnectedPeripheral、disableEmulation 三个方法的确切语义,掌握配合 waitForDevicePrompt 完成 Web Bluetooth 设备请求自动化的完整流程,并明确该功能在 Chromium 中的隔离边界与实验性约束。
一、BluetoothEmulation 接口概述
BluetoothEmulation 是 Puppeteer 暴露的蓝牙模拟能力接口,官方文档描述其为 "Exposes the bluetooth emulation abilities"(暴露蓝牙模拟能力),通过 page.bluetooth 属性访问。接口签名如下:
export interface BluetoothEmulation
该接口定义了三个方法,全部标记为 Experimental(实验性):
| 方法 | 说明 |
|---|---|
| disableEmulation() | 禁用已模拟的蓝牙适配器。对应 Web Bluetooth 规范中的 bluetooth.disableSimulation 命令 |
| emulateAdapter(state, leSupported) | 模拟蓝牙适配器,是所有蓝牙模拟操作的前提。对应规范中的 bluetooth.simulateAdapter 命令 |
| simulatePreconnectedPeripheral(preconnectedPeripheral) | 模拟一个预连接的蓝牙外设。对应规范中的 bluetooth.simulatePreconnectedPeripheral 命令 |
由于三个方法均为实验性 API,接口在源码中同样以 @experimental 标注(见 接口定义文件)。这意味着 API 签名可能随版本演进调整,生产环境使用时应关注版本变更。
作用域限制:浏览器上下文级而非页面级
接口文档中最重要的 Remarks 指出:
Web Bluetooth specification requires the emulated adapters should be isolated per top-level navigable. However, at the moment Chromium's bluetooth emulation implementation is tight to the browser context, not the page. This means the bluetooth emulation exposed from different pages of the same browser context would interfere their states.
即规范要求模拟器按顶层可导航对象(页面)隔离,但当前 Chromium 的实现将模拟绑定在浏览器上下文(browser context) 层面。同一个浏览器上下文中的不同页面会互相干扰蓝牙模拟状态。
这一限制在 CDP 实现中有直接体现。从 CdpPage 构造函数 可以看到:
// Use browser context's connection, as current Bluetooth emulation in Chromium is
// implemented on the browser context level, and not tight to the specific tab.
this.#cdpBluetoothEmulation = new CdpBluetoothEmulation(
this.#primaryTargetClient.connection(),
);
源码注释明确说明:CDP 蓝牙模拟命令通过浏览器上下文级别的连接(而非标签页会话)发送。因此编写测试时,若要避免状态串扰,应使用独立浏览器上下文(browser.createBrowserContext())隔离各用例。
二、核心类型定义
接口涉及三个公共类型,全部定义在 packages/puppeteer-core/src/api/BluetoothEmulation.ts:
AdapterState:适配器状态
export type AdapterState = 'absent' | 'powered-off' | 'powered-on';
模拟的蓝牙适配器支持三种状态(详见 AdapterState 文档):
| 取值 | 含义 |
|---|---|
absent |
设备不存在蓝牙适配器 |
powered-off |
适配器存在但已关闭 |
powered-on |
适配器存在且已开启(进行蓝牙模拟的前提) |
BluetoothManufacturerData:厂商数据
export interface BluetoothManufacturerData {
/**
* The company identifier, as defined by the Bluetooth SIG.
*/
key: number;
/**
* The manufacturer-specific data as a base64-encoded string.
*/
data: string;
}
key:蓝牙 SIG 定义的公司标识符(company identifier);data:厂商特定数据,必须以 base64 编码字符串传入。
类型定义见 BluetoothManufacturerData 文档。
PreconnectedPeripheral:预连接外设
export interface PreconnectedPeripheral {
address: string;
name: string;
manufacturerData: BluetoothManufacturerData[];
knownServiceUuids: string[];
}
四个字段均有实际类型约束(见 PreconnectedPeripheral 文档):
| 字段 | 说明 |
|---|---|
address |
外设蓝牙地址,如 '09:09:09:09:09:09' |
name |
外设名称,如 'SOME_NAME' |
manufacturerData |
厂商数据数组,每项含 key/data |
knownServiceUuids |
已知服务 UUID 列表,如 ['12345678-1234-5678-9abc-def123456789'] |
三、三个方法的签名与参数
emulateAdapter(state, leSupported)
interface BluetoothEmulation {
emulateAdapter(state: AdapterState, leSupported?: boolean): Promise<void>;
}
state(AdapterState):期望的适配器状态;leSupported(boolean,可选):标记该适配器是否支持低功耗蓝牙(LE)。从源码签名emulateAdapter(state: AdapterState, leSupported = true)可见,默认值为true;- 返回
Promise<void>。
该方法是所有蓝牙模拟操作的前置条件:必须先让"适配器"处于开启状态,页面中的 navigator.bluetooth API 才能发现设备。
simulatePreconnectedPeripheral(preconnectedPeripheral)
interface BluetoothEmulation {
simulatePreconnectedPeripheral(
preconnectedPeripheral: PreconnectedPeripheral,
): Promise<void>;
}
preconnectedPeripheral(PreconnectedPeripheral):要模拟的外设对象;- 返回
Promise<void>。
调用后,该外设会以"已发现"状态出现在页面的设备选择提示中,可供 DeviceRequestPrompt.select() 选中。
disableEmulation()
interface BluetoothEmulation {
disableEmulation(): Promise<void>;
}
无参数,返回 Promise<void>,用于结束模拟、恢复浏览器真实蓝牙状态,避免影响后续用例。
四、完整使用示例
文档给出的标准用法(与源码 JSDoc 中 @example 一致):
await page.bluetooth.emulateAdapter('powered-on');
await page.bluetooth.simulatePreconnectedPeripheral({
address: '09:09:09:09:09:09',
name: 'SOME_NAME',
manufacturerData: [
{
key: 17,
data: 'AP8BAX8=',
},
],
knownServiceUuids: ['12345678-1234-5678-9abc-def123456789'],
});
await page.bluetooth.disableEmulation();
调用顺序即完整生命周期:开启适配器 → 注入预连接外设 → 在页面中触发 navigator.bluetooth.requestDevice() 完成断言 → 关闭模拟。示例中 key: 17 与 data: 'AP8BAX8=' 分别演示了公司标识符数字和 base64 编码数据两种取值形态。
五、底层实现:CDP 与 WebDriver BiDi 双通道
Puppeteer 对该接口提供了两套实现,均位于 packages/puppeteer-core/src/ 下:
CDP 实现:CdpBluetoothEmulation
CdpBluetoothEmulation 通过 Connection 直接发送 CDP 命令:
async emulateAdapter(state: AdapterState, leSupported = true): Promise<void> {
// Bluetooth spec requires overriding the existing adapter (step 6). From the CDP
// perspective, it means disabling the emulation first.
await this.#connection.send('BluetoothEmulation.disable');
await this.#connection.send('BluetoothEmulation.enable', {
state,
leSupported,
});
}
async disableEmulation(): Promise<void> {
await this.#connection.send('BluetoothEmulation.disable');
}
async simulatePreconnectedPeripheral(
preconnectedPeripheral: PreconnectedPeripheral,
): Promise<void> {
await this.#connection.send(
'BluetoothEmulation.simulatePreconnectedPeripheral',
preconnectedPeripheral,
);
}
两个值得注意的实现细节:
emulateAdapter会先发送BluetoothEmulation.disable再发送enable。源码注释解释这是规范要求的行为——Web Bluetooth 规范的simulateAdapter命令第 6 步要求覆盖(override)已存在的适配器,因此在 CDP 层面需要先禁用再启用。这也意味着连续调用emulateAdapter是幂等安全的。- 构造函数接收的是浏览器上下文级连接(见上文
CdpPage中的构造位置),这解释了文档 Remarks 中"上下文级隔离"的限制来源。
BiDi 实现:BidiBluetoothEmulation
BidiBluetoothEmulation 面向 WebDriver BiDi 协议,所有命令都显式携带 context(上下文 ID),将模拟作用域限定在指定浏览器上下文:
async emulateAdapter(state: AdapterState, leSupported = true): Promise<void> {
await this.#session.send('bluetooth.simulateAdapter', {
context: this.#contextId,
state,
leSupported,
});
}
BiDi 版 simulatePreconnectedPeripheral 会显式解构外设对象,将 address、name、manufacturerData、knownServiceUuids 逐字段展平后发送 bluetooth.simulatePreconnectedPeripheral 命令。对比两套实现可以看到:CDP 版整体透传 preconnectedPeripheral,BiDi 版按协议 schema 逐字段映射——接口抽象层(Page.bluetooth 抽象 getter)保证了用户代码在两种协议间无需改动。
六、端到端实战:配合 waitForDevicePrompt 完成设备选择
仓库测试 test/src/bluetooth-emulation.test.ts 展示了模拟蓝牙后与页面交互的完整闭环,其中包含几个文档示例未覆盖的关键前提:
1. 浏览器启动参数。测试通过 setupSeparateTestBrowserHooks 指定:
args: [
'--enable-features=WebBluetoothNewPermissionsBackend',
'--enable-features=WebBluetooth',
],
acceptInsecureCerts: true,
即需要 WebBluetoothNewPermissionsBackend 与 WebBluetooth 两个 feature flag,并且页面必须运行在安全上下文(测试使用 httpsServer.EMPTY_PAGE)。
2. 标准交互流程。以"选择设备"用例为例:
await page.goto(httpsServer.EMPTY_PAGE);
await page.bluetooth.emulateAdapter('powered-on');
await page.bluetooth.simulatePreconnectedPeripheral(SIMULATED_PERIPHERAL);
const devicePromptPromise = page.waitForDevicePrompt();
const navigatorRequestDevicePromise = page.evaluate(
triggerBluetoothDevicePrompt, // 内部调用 navigator.bluetooth.requestDevice
);
// 等待设备提示出现,然后选中模拟设备
const devicePrompt = await devicePromptPromise;
await devicePrompt.select(devicePrompt.devices[0]!);
// 断言:requestDevice 解析为模拟外设名称
expect(await navigatorRequestDevicePromise).toEqual(DEVICE_NAME);
其中 DeviceRequestPrompt 由 page.waitForDevicePrompt() 返回(定义见 DeviceRequestPrompt 文档),核心成员有:
devices(readonly):当前可选设备列表;select(device):选中提示列表中的某个设备;cancel():取消提示(测试用例验证了取消后requestDevice会 reject);waitForDevice(filter, options):等待并解析第一个匹配过滤条件的设备。
典型写法是将 waitForDevicePrompt() 与触发请求的动作用 Promise.all 配对,例如点击页面上的"连接蓝牙"按钮:
const [devicePrompt] = Promise.all([
page.waitForDevicePrompt(),
page.click('#connect-bluetooth'),
]);
await devicePrompt.select(
await devicePrompt.waitForDevice(({name}) => name.includes('My Device')),
);
Page 类对 waitForDevicePrompt 的注释特别提醒:该方法必须在设备请求发起之前调用,否则无法返回提示对象。
3. 模拟数据的复用。测试中的 SIMULATED_PERIPHERAL 常量与文档示例逐字段一致:地址 09:09:09:09:09:09、名称 SOME_NAME、厂商数据 {key: 17, data: 'AP8BAX8='}、服务 UUID 12345678-1234-5678-9abc-def123456789,可直接作为复制模板。
七、小结
BluetoothEmulation 接口以三个实验性方法提供了完整的 Web Bluetooth 模拟生命周期:用 emulateAdapter 设定 absent / powered-off / powered-on 三态适配器(LE 支持默认开启),用 simulatePreconnectedPeripheral 注入含地址、名称、base64 厂商数据与服务 UUID 的虚拟外设,再用 disableEmulation 收尾。理解它需要抓住三层事实:接口层定义于 packages/puppeteer-core/src/api/BluetoothEmulation.ts;实现层由 CDP 通道(先 disable 后 enable 覆盖旧适配器)与 BiDi 通道(按 context 限定作用域)分别落地;使用层则需配合 waitForDevicePrompt 与浏览器 feature flag 完成端到端断言。同时务必记住文档强调的约束——Chromium 当前的模拟绑定在浏览器上下文而非页面,同上下文多页面间的模拟状态会互相干扰,测试编排时宜用独立上下文隔离。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00