基于Bangle.js 2智能手表的BLE嗅探器开发实战

📅 2026/8/19 13:35:38
基于Bangle.js 2智能手表的BLE嗅探器开发实战
1. 项目缘起为什么要在智能手表上做BLE嗅探几年前我在做一个智能家居项目需要调试一堆BLE设备。当时我手头有手机、有专门的蓝牙分析仪但总觉得差点意思。手机App功能强大但屏幕太大揣在兜里不方便随时掏出来看专业分析仪又太笨重而且价格不菲。直到我偶然把玩起手边的Bangle.js 2这块开源智能手表一个念头冒了出来它本身就是一个基于Nordic nRF52840芯片的蓝牙设备能不能把它变成一个便携、常戴在手腕上的BLE信号“雷达”呢这个想法并非空穴来风。Bangle.js 2的核心——nRF52840是一颗蓝牙5.2/低功耗蓝牙BLE双模芯片性能相当强悍。它不仅能作为外设Peripheral广播自己的数据更能作为中心设备Central去扫描和连接其他BLE设备。我们日常用手机扫描蓝牙设备本质上就是手机作为中心设备在工作。那么理论上让手表运行一个程序持续扫描周围的BLE广播包解析并显示关键信息是完全可行的。这不仅仅是“为了酷”。在实际开发和运维中这种便携式嗅探器有独特的价值。比如在部署大量BLE信标Beacon的商场或仓库你可以边走边看实时检查每个信标的信号强度RSSI、广播间隔是否正常有没有设备意外掉线。再比如当你开发一个BLE产品时可以把它戴在手上在办公室、家里等真实环境中走动直观地看到你的设备广播信号在不同位置、不同障碍物下的覆盖情况和稳定性这比在固定点测试要生动得多。甚至对于物联网安全爱好者它也可以是一个初步的、随身携带的BLE环境感知工具。所以这个项目“BLE Detection with the Bangle.js 2 smartwatch”的核心就是将Bangle.js 2手表变身为一个可穿戴、低功耗、实时显示的BLE广播嗅探与可视化终端。它不涉及复杂的连接与数据通信专注于“听”——监听空中那些此起彼伏的BLE广播报文并把听到的信息以最直观的方式呈现在那块1.3英寸的圆形屏幕上。2. 核心原理BLE广播与扫描是如何工作的要让手表“听到”BLE设备首先得明白BLE设备是怎么“说话”的。BLE设备为了被发现和连接会周期性地向外发送广播报文Advertising Packet。这就像一个人站在房间里定时喊一句“我在这儿我是XXX我能提供YYY服务”。广播报文里包含了几个关键部分广播地址Advertising Address通常是设备的MAC地址是它的唯一标识符。就像每个人的身份证号。广播数据Advertising Data这是一段长度不超过31字节的灵活区域里面可以塞入各种信息。它由若干个“AD Structure”组成每个结构包含一个长度字节、一个类型字节AD Type和对应的数据。常见的类型有0x01Flags标识设备能力如是否支持BR/EDR传统蓝牙是否可被发现等。0x03Complete List of 16-bit Service UUIDs设备支持的16位标准服务列表。0x09Complete Local Name设备的完整名称比如“My_Thermometer”。0xFFManufacturer Specific Data制造商自定义数据这是很多物联网设备传递自定义信息如温度、电量、状态的主要通道。扫描响应数据Scan Response Data当中心设备如我们的手表对某个广播感兴趣可以主动发送一个扫描请求Scan Request。被扫描的设备则会回复一个扫描响应包其格式与广播数据类似可以携带额外的信息如更长的设备名。这就像你听到有人喊可以追问一句“你叫啥名”对方会给你一个更详细的回答。在Bangle.js 2的Espruino它运行的JavaScript引擎环境中我们主要通过NRF.findDevices()这个API来扮演“听”的角色。调用这个函数后手表会启动蓝牙扫描。每当它收到一个广播包就会触发回调函数并传入一个设备信息对象。这个对象里就包含了我们最关心的几样东西id设备的唯一标识通常就是广播地址。rssi接收信号强度指示单位是dBm。这个值越接近0但永远是负数信号越强。它是判断设备距离和信号质量最直观的指标。-50dBm通常表示信号极好-70dBm算良好-90dBm可能就处于连接断开的边缘了。data和scanData这两个属性分别对应广播数据和扫描响应数据是原始的字节数组。我们需要编写解析函数才能从中提取出设备名、服务UUID、厂商数据等可读信息。整个扫描过程是异步且事件驱动的。我们的程序结构大致是启动扫描 - 设置回调 - 在回调中解析并更新显示 - 根据需求如过滤、排序刷新屏幕。这里的一个关键点是功耗管理。持续高频率扫描会非常耗电。因此在实际程序中我们需要合理设置扫描窗口和间隔或者在不需要时主动停止扫描NRF.findDevices()传入回调函数即开始传入null或undefined则停止。3. 实战从零构建Bangle.js 2 BLE嗅探器理论清楚了我们开始动手。你需要准备一块Bangle.js 2手表并通过USB线将其连接到电脑。开发环境主要是Web IDE访问 https://www.espruino.com/ide/ 即可。3.1 基础扫描与列表显示我们先实现一个最基础的版本扫描设备并在屏幕上列出它们的名称和RSSI。// 存储发现的设备以id为key避免重复 let devices {}; // 扫描回调函数 function onDevicesFound(device) { let id device.id; // 如果设备已存在更新其信息特别是RSSI否则新增 if (!devices[id]) { devices[id] { id: id, name: parseDeviceName(device), // 解析设备名 rssi: device.rssi, lastSeen: Date.now() }; } else { devices[id].rssi device.rssi; devices[id].lastSeen Date.now(); } // 更新显示 drawDeviceList(); } // 解析设备名称 function parseDeviceName(dev) { // 优先从scanData中找完整名称 let name parseAdvertisingData(dev.scanData, 0x09); if (!name) { // 再从广播数据中找 name parseAdvertisingData(dev.data, 0x09); } // 如果还是找不到用短ID代替 return name || dev.id.substr(12, 5); } // 通用AD数据解析函数 function parseAdvertisingData(data, type) { if (!data) return null; let i 0; while (i data.length) { let len data[i]; if (len 0) break; let adType data[i1]; if (adType type) { // 提取数据并转为字符串假设是UTF-8 return E.toString(data.slice(i2, i1len)); } i 1 len; // 移动到下一个AD Structure } return null; } // 绘制设备列表 function drawDeviceList() { g.clear(); g.setFontAlign(0, -1); // 左对齐顶部对齐 g.setFont(6x8); let y 10; let deviceArray Object.values(devices); // 按RSSI从强到弱排序 deviceArray.sort((a, b) b.rssi - a.rssi); // 显示标题 g.drawString(BLE Devices (${deviceArray.length}):, 10, y); y 15; // 显示前8个设备受屏幕限制 for (let i 0; i Math.min(deviceArray.length, 8); i) { let d deviceArray[i]; let line ${d.name}: ${d.rssi} dBm; g.drawString(line, 10, y); y 12; } // 显示提示 g.setFont(4x6); g.drawString(Press BTN1 to clear, 10, 200); g.flip(); } // 主程序入口 function startScan() { g.clear(); g.drawString(Starting BLE Scan..., 40, 100); g.flip(); devices {}; // 清空旧数据 // 设置扫描参数持续扫描不设超时 NRF.findDevices(onDevicesFound, { timeout : 0, active : true }); // 绑定按钮事件BTN1清除列表并重启扫描 setWatch(function() { NRF.findDevices(); // 停止扫描 startScan(); // 重新开始 }, BTN1, { edge: rising, repeat: true, debounce: 50 }); } // 启动 startScan();把这段代码粘贴到Web IDE的右侧编辑器点击“Send to Espruino”上传到手表中。你会看到屏幕开始显示扫描到的设备及其RSSI。按下手表的BTN1按钮可以清空列表并重新开始扫描。注意NRF.findDevices()的active参数设为true表示主动扫描这意味着手表不仅会接收广播包还会发送扫描请求来获取扫描响应数据这样更有可能拿到完整的设备名。但这会比被动扫描active: false稍微耗电一点。3.2 进阶信号强度RSSI可视化与过滤单纯的列表还不够直观。接下来我们实现一个信号“雷达图”或者柱状图并增加过滤功能只显示我们关心的设备比如特定厂商ID的设备。首先修改drawDeviceList函数改为绘制RSSI柱状图function drawDeviceList() { g.clear(); g.setFontAlign(0, -1); g.setFont(6x8); let deviceArray Object.values(devices); deviceArray.sort((a, b) b.rssi - a.rssi); // 绘制标题和计数 g.drawString(BLE Radar - ${deviceArray.length} found, 60, 5); let y 25; let barWidth 100; // 柱状图最大宽度 for (let i 0; i Math.min(deviceArray.length, 6); i) { let d deviceArray[i]; // RSSI范围大致从-100到-40映射到柱状图宽度 let strength Math.max(0, Math.min(1, (d.rssi 100) / 60)); let barLength Math.floor(strength * barWidth); // 绘制设备名截断 let name d.name.length 10 ? d.name.substr(0, 10) .. : d.name; g.drawString(name, 10, y); // 绘制RSSI数值 g.drawString(${d.rssi}, 130, y); // 绘制柱状图 g.fillRect(50, y, 50 barLength, y 8); y 15; } // 底部状态栏 g.setFont(4x6); g.drawString(BTN1:Clear/Rescan | BTN2:Toggle Filter, 10, 200); g.flip(); }然后我们增加一个根据厂商ID过滤的功能。假设我们只关心 Nordic Semiconductor厂商ID为0x0059的设备let filterEnabled false; const TARGET_MANUFACTURER_ID 0x0059; // Nordic function parseManufacturerData(data) { if (!data) return null; let i 0; while (i data.length) { let len data[i]; if (len 0) break; let adType data[i1]; if (adType 0xFF) { // Manufacturer Specific Data // 前两个字节是厂商ID (Little Endian) let mfgId (data[i3] 8) | data[i2]; return { id: mfgId, data: data.slice(i4, i1len) }; } i 1 len; } return null; } function onDevicesFound(device) { let id device.id; // 应用过滤器 if (filterEnabled) { let mfgData parseManufacturerData(device.data) || parseManufacturerData(device.scanData); if (!mfgData || mfgData.id ! TARGET_MANUFACTURER_ID) { // 如果不是目标厂商设备且之前已记录则删除 if (devices[id]) { delete devices[id]; drawDeviceList(); } return; // 忽略此设备 } } // ... 原有的更新逻辑 ... if (!devices[id]) { devices[id] { id: id, name: parseDeviceName(device), rssi: device.rssi, lastSeen: Date.now(), mfgId: parseManufacturerData(device.data)?.id || parseManufacturerData(device.scanData)?.id }; } else { devices[id].rssi device.rssi; devices[id].lastSeen Date.now(); } drawDeviceList(); } // 在startScan中增加BTN2绑定用于切换过滤器 function startScan() { // ... 之前的代码 ... setWatch(function() { // BTN1 NRF.findDevices(); startScan(); }, BTN1, { edge: rising, repeat: true, debounce: 50 }); setWatch(function() { // BTN2 filterEnabled !filterEnabled; devices {}; // 清空列表重新过滤 g.clear(); g.drawString(Filter: ${filterEnabled ? ON (Nordic Only) : OFF}, 40, 100); g.flip(); setTimeout(drawDeviceList, 1000); }, BTN2, { edge: rising, repeat: true, debounce: 50 }); }现在你可以通过按下BTN2来切换过滤器。当过滤器开启时屏幕上只会显示厂商ID为Nordic0x0059的BLE设备这对于在混杂环境中定位特定品牌的开发板或产品非常有用。3.3 核心调试技巧深入解析广播数据包列表和图表显示了“谁”在附近以及“信号有多强”但对于开发者我们更想知道它们“在说什么”。我们需要一个更详细的视图来解析广播数据包。我们可以设计一个模式当点击或通过按钮选择某个设备时进入详情页展示其广播数据的完整解析。首先我们需要一个更强大的解析函数能列出所有AD结构function parseAllAdvertisingData(data) { let result []; if (!data) return result; let i 0; while (i data.length) { let len data[i]; if (len 0) break; let adType data[i1]; let adData data.slice(i2, i1len); let typeName getAdTypeName(adType); let dataStr ; // 根据类型进行格式化 switch(adType) { case 0x01: // Flags dataStr 0x adData.toString(16); break; case 0x03: // 16-bit UUIDs case 0x05: // 32-bit UUIDs case 0x07: // 128-bit UUIDs let uuids []; for (let j0; jadData.length; j (adType0x03?2: (adType0x05?4:16))) { let hex E.toString(adData.slice(j, j(adType0x03?2: (adType0x05?4:16)))); uuids.push(0x hex.split().reverse().join()); // BLE UUID是Little Endian } dataStr uuids.join(, ); break; case 0x09: // Complete Local Name case 0x08: // Shortened Local Name dataStr E.toString(adData); break; case 0xFF: // Manufacturer Data let mfgId (adData[1]8) | adData[0]; dataStr MfgID: 0x${mfgId.toString(16).padStart(4,0)}, Data: 0x${E.toString(adData.slice(2))}; break; default: dataStr 0x E.toString(adData); } result.push({ type: adType, typeName: typeName, length: len, data: adData, dataStr: dataStr }); i 1 len; } return result; } function getAdTypeName(type) { const names { 0x01: Flags, 0x02: Incomplete 16-bit UUIDs, 0x03: Complete 16-bit UUIDs, 0x04: Incomplete 32-bit UUIDs, 0x05: Complete 32-bit UUIDs, 0x06: Incomplete 128-bit UUIDs, 0x07: Complete 128-bit UUIDs, 0x08: Short Local Name, 0x09: Complete Local Name, 0x0A: Tx Power Level, 0xFF: Manufacturer Data // 可以继续添加更多... }; return names[type] || Unknown (0x${type.toString(16)}); }然后修改程序逻辑使其能够进入设备详情视图。这需要引入一个状态机。为了简化我们可以用BTN3来切换模式模式1是雷达列表模式2是设备详情。在列表模式下用上下按钮假设用BTN1/BTN2模拟选择设备按中间键BTN3进入详情。由于Bangle.js 2只有三个物理按钮BTN1, BTN2, BTN3我们需要重新分配一下功能或者使用手势。这里为了演示我们采用一个简化方案在列表界面长按BTN3进入当前信号最强设备的详情页在详情页短按BTN3返回列表。我们需要重构一下主循环和绘制逻辑。这会让代码变长但结构更清晰。核心是维护一个currentMode变量list或detail和当前选中的设备selectedDeviceId。由于篇幅限制这里概述关键改动全局状态let currentMode list; let selectedDeviceId null; let devices {};修改onDevicesFound只更新devices对象不直接调用draw。由一个统一的draw()函数根据currentMode决定绘制内容。统一的draw()函数function draw() { g.clear(); if (currentMode list) { drawDeviceList(); } else if (currentMode detail selectedDeviceId devices[selectedDeviceId]) { drawDeviceDetail(devices[selectedDeviceId]); } g.flip(); }详情绘制函数drawDeviceDetailfunction drawDeviceDetail(dev) { g.setFont(6x8); g.drawString(Detail: ${dev.name}, 10, 10); g.drawString(ID: ${dev.id}, 10, 25); g.drawString(RSSI: ${dev.rssi} dBm, 10, 40); let y 60; g.drawString(Advertising Data:, 10, y); y12; let adStructures parseAllAdvertisingData(dev.rawData); // 需要保存原始数据 for (let s of adStructures) { let line ${s.typeName}: ${s.dataStr}; // 处理长字符串换行显示 // ... 具体换行逻辑略 ... y 12; if (y 180) break; } g.setFont(4x6); g.drawString(BTN3: Back to List, 10, 200); }按钮控制逻辑// 在startScan中设置 setWatch(function() { // BTN3 短按/长按 if (currentMode list) { // 长按进入详情这里简化实际需用setTimeout判断长按 let deviceArray Object.values(devices).sort((a,b)b.rssi-a.rssi); if (deviceArray.length 0) { selectedDeviceId deviceArray[0].id; currentMode detail; draw(); } } else if (currentMode detail) { // 短按返回列表 currentMode list; draw(); } }, BTN3, { edge: rising, repeat: true, debounce: 50 });通过这样的详情页你可以清晰地看到目标设备广播包里每一个AD结构的具体内容这对于调试自定义的厂商数据、验证服务UUID是否正确包含等场景至关重要。4. 避坑指南与性能优化在实际把玩这个BLE嗅探器的过程中我踩过几个坑也总结了一些优化点分享给你。坑一设备列表刷新过快导致屏幕闪烁NRF.findDevices()的回调频率可能很高如果每次回调都调用g.clear()和重绘整个屏幕会导致屏幕剧烈闪烁体验很差。解决方案采用增量更新和节流绘制。不要在每个回调中都重绘。可以设置一个定时器比如每200毫秒检查一次设备列表是否有更新有更新再重绘。或者在回调中只更新内存中的devices对象而通过一个固定的间隔如setInterval(draw, 250)来驱动界面刷新。坑二内存泄漏与设备列表膨胀如果不加处理devices对象会越来越大因为旧设备即使已经离开范围也不会被自动移除。长时间运行可能导致内存不足。解决方案实现一个“老化”机制。在每个设备对象中记录lastSeen时间戳。在绘制前或定时任务中遍历devices删除那些超过一定时间比如10秒没有被更新的设备。function cleanupOldDevices(maxAge 10000) { // 10秒 let now Date.now(); for (let id in devices) { if (now - devices[id].lastSeen maxAge) { delete devices[id]; } } } // 在draw函数或一个定时器中调用它坑三扫描功耗与系统冲突持续扫描是耗电大户。另外Bangle.js 2的系统应用如消息通知、心率监测也可能需要使用蓝牙可能会与我们的扫描程序冲突。解决方案优化扫描参数NRF.findDevices()可以传入interval和window参数。增大interval扫描间隔可以显著降低功耗但会降低设备发现速度。这是一个需要权衡的点。对于只是偶尔查看环境的场景可以设置为{ interval: 1000, window: 200 }即每秒扫描200毫秒。提供暂停功能增加一个按钮或手势控制让用户可以暂停扫描屏幕保持最后状态。暂停时调用NRF.findDevices()停止扫描。优雅退出在应用退出时务必停止扫描。可以在E.on(kill, ...)事件中处理。E.on(kill, function() { NRF.findDevices(); // 停止扫描 });坑四屏幕显示信息有限圆形屏幕尺寸小显示多行文本时容易重叠或超出边界。解决方案精心布局使用小字体4x6,6x8计算好每行位置。文本截断与滚动对于长设备名或数据进行截断并添加“..”。对于详情页的长列表可以实现滚动查看。这需要记录一个scrollOffset变量并通过按钮如BTN1/BTN2来控制上下滚动。图形化替代如前所述用柱状图代替纯数字显示RSSI更直观且节省空间。性能优化点减少字符串操作在解析广播数据时频繁的E.toString()和字符串拼接在资源受限的设备上可能成为瓶颈。优化建议对于非关键信息或不需要显示的解析可以延迟处理或仅在需要时处理。例如在列表模式下只解析设备名只有进入详情模式时才完整解析所有AD结构并格式化为字符串。5. 扩展思路从嗅探到简单交互一个纯粹的嗅探器已经很有用了但Bangle.js 2的能力不止于此。我们可以基于扫描结果进行一些简单的交互让它变成一个真正的调试工具。想法一信号强度日志记录有时候我们需要记录一个移动中的BLE设备比如戴在身上的另一个手环的信号强度变化。我们可以修改程序当锁定某个特定设备通过ID或名称过滤后将其RSSI和时间戳记录到手表的存储中。let logEnabled false; let logDeviceId null; let logData []; function startLogging(targetId) { logEnabled true; logDeviceId targetId; logData []; } function onDevicesFound(device) { // ... 原有逻辑 ... if (logEnabled device.id logDeviceId) { logData.push({ t: Date.now(), r: device.rssi }); // 可以限制日志长度比如只保留最近100条 if (logData.length 100) logData.shift(); } }之后可以通过一个单独的视图来绘制RSSI随时间变化的折线图或者将logData通过蓝牙串口导出到电脑分析。想法二接近警报设定一个RSSI阈值比如-70。当某个特定设备如你的自行车上的BLE标签的信号强度高于这个阈值意味着距离变近时让手表震动提醒。这可以用来防丢或者作为某种触发机关。const TARGET_DEVICE_NAME MyBikeTag; const ALERT_RSSI_THRESHOLD -70; function onDevicesFound(device) { let name parseDeviceName(device); if (name.indexOf(TARGET_DEVICE_NAME) ! -1) { if (device.rssi ALERT_RSSI_THRESHOLD) { // 设备很近了 if (!Bangle.isVibrating()) { Bangle.buzz(); // 短震动 // 或者显示醒目提示 g.setColor(1,0,0); // 红色 g.fillRect(0,0,176,176); g.setColor(1,1,1); g.setFont(6x8,2); g.drawString(NEAR!, 60, 80); g.flip(); setTimeout(() { draw(); }, 2000); // 2秒后恢复 } } } }想法三模拟广播既然能收那能不能发当然可以。NRF.setAdvertising()可以让Bangle.js 2自己广播数据。你可以写一个简单的界面让手表模拟成一个iBeacon或者Eddystone Beacon设置其UUID、Major、Minor和发射功率。这样你就可以用手机或其他BLE扫描工具来检测你的手表验证广播内容是否正确这对于学习BLE广播协议或测试定位算法非常有帮助。实现这些扩展意味着你的Bangle.js 2从一个被动的“耳朵”变成了一个能与BLE世界进行简单对话的“瑞士军刀”。这些功能代码量不大但能极大丰富这个项目的实用性和趣味性。