基于Bangle.js LE环境感知:从扫描原理到嵌入式JavaScript实现 1. 项目概述用智能手表玩转BLE环境感知最近在捣鼓一个挺有意思的小项目用我的Bangle.js 2智能手表来探测周围的蓝牙低功耗BLE设备。你可能觉得这听起来不就是个蓝牙扫描器吗手机App也能干。但当你把探测能力集成到一块可以戴在手腕上、续航长达一周、还能独立运行JavaScript程序的智能手表里时事情就变得好玩多了。想象一下你不需要掏出手机抬腕就能看到周围有多少蓝牙设备、它们的信号强度如何甚至能根据信号变化判断设备是否在移动。这对于物联网爱好者、创客或者只是想了解自己身边“无线环境”的人来说是个非常酷的切入点。Bangle.js 2是一款开源的、可编程的智能手表核心是基于Nordic Semiconductor的nRF52832芯片这颗芯片本身就是一个强大的BLE中枢兼外设。它运行着Espruino JavaScript解释器意味着你可以用熟悉的JS语言直接操作硬件调用诸如NRF.findDevices这样的底层API来扫描设备、读取RSSI接收信号强度指示。这个项目的核心就是利用这些能力构建一个手表端的BLE环境感知工具。无论是用来排查智能家居设备的连接状态寻找丢失的蓝牙标签还是单纯作为一个极客向的无线信号可视化工具都很有实用价值。2. 核心思路与方案设计2.1 为什么选择Bangle.js 2市面上能编程的手表不少但Bangle.js 2有几个独特的优势让它成为这个项目的理想平台。首先完全开源与可编程性。从硬件原理图到固件全部开放。Espruino提供的JavaScript运行时环境让开发门槛极低你不需要折腾复杂的嵌入式C/C编译链写几行JS代码通过Web IDE就能直接烧录和调试这对于快速原型开发至关重要。其次硬件性能与续航平衡。nRF52832芯片提供了可靠的BLE 5.0支持足以胜任扫描任务。手表虽然小巧但拥有足够的内存64KB RAM512KB Flash来存储和处理扫描到的设备信息。更重要的是它的功耗控制得很好。在间歇性扫描的模式下手表可以持续工作数天这比一直开着手机扫描要方便和持久得多。最后即时的交互与显示。手表屏幕虽然不大但足以清晰地显示关键信息如设备数量、最强的RSSI值等。物理按钮和触摸屏提供了直接的交互方式你可以随时启动、停止扫描或切换显示模式这种“抬腕即看”的体验是手机无法比拟的。2.2 整体软件架构设计这个BLE探测应用的架构并不复杂但需要合理组织以兼顾功能、性能和用户体验。核心流程围绕NRF.findDevices这个API展开。1. 扫描管理模块这是应用的核心引擎。我们需要控制扫描的周期、持续时间和过滤条件。持续扫描会迅速耗尽电量因此必须采用间歇扫描策略。例如每10秒进行一次为期2秒的主动扫描其余时间让蓝牙模块进入低功耗状态。同时我们可以设置扫描过滤参数比如只扫描特定服务UUID的设备或者接收所有广播包这取决于你想关注什么。2. 数据处理与缓存模块扫描到的设备数据是原始的包含设备地址、广播数据、RSSI等。我们需要一个缓存池来管理这些设备。常见的策略是使用一个对象或Map以设备地址为键存储设备信息以及最后一次被看到的时间戳。我们还需要一个老化清理机制定期移除长时间比如超过60秒未被扫描到的设备确保列表的实时性。3. 用户界面UI渲染模块在有限的屏幕空间176x176像素内有效展示信息是关键。设计上可以采用分层显示 *概览视图显示当前扫描到的设备总数、信号最强的设备名及其RSSI。 *列表视图滚动显示所有已发现设备的详细信息包括名称或地址、RSSI值和“新鲜度”多久前被发现。 *图表视图用简单的条形图或曲线图展示特定设备RSSI值随时间的变化趋势有助于判断设备距离或移动状态。4. 交互控制模块通过手表的物理按钮上、下、中或触摸屏手势来切换视图、启动/停止扫描、清空列表等。2.3 关键APINRF.findDevices深度解析整个项目的基石是NRF.findDevices()函数。它的工作原理是让手表的蓝牙无线电接收器监听特定频段上的BLE广播包。几乎所有BLE设备如信标、传感器、耳机都会间歇性地发送这些广播包以宣告自己的存在。调用这个函数时可以传入一个配置对象其中几个参数对项目影响很大NRF.findDevices({ timeout: 2000, // 扫描持续毫秒数 filters: [...], // 过滤器数组可按服务UUID过滤 active: true, // 是否主动扫描请求扫描响应耗电稍高但信息全 whitelist: false, // 是否使用白名单过滤 }, function(devices) { // 扫描回调devices是一个数组 });timeout决定了每次扫描的“监听窗口”有多长。太短可能错过广播周期长的设备太长则浪费电量。2-3秒是一个常用折中值。active设置为true时手表在收到广播后会发送一个扫描请求设备可能回复一个包含更多信息的“扫描响应”包。这能获取到更完整的设备名称和服务列表但功耗略高。对于只需要探测存在和信号强度的场景active: false被动扫描更省电。filters如果你想专注于某一类设备比如所有发布了“心率服务”的手环可以在这里设置服务UUID过滤器。如果不设置则接收所有广播。回调函数返回的devices数组中的每个对象都包含宝贵信息id设备蓝牙地址是设备的唯一标识。rssi接收信号强度指示单位是dBm。这个值是负的数值越大越接近0信号越强。例如-40 dBm的信号比-80 dBm强得多。它是判断设备相对距离的核心依据。data/scanResp设备的广播数据和扫描响应数据里面可能包含设备名称、制造商信息、服务列表等。注意NRF.findDevices是一个一次性调用。每次你想扫描都需要重新调用它。因此在我们的应用里需要用一个setInterval或递归函数调用来实现周期性扫描。3. 核心功能实现与代码拆解3.1 基础扫描与设备列表构建让我们从最核心的扫描循环开始。下面的代码实现了一个简单的、每10秒扫描一次的基础版本// 存储设备的缓存以设备id为键 let deviceCache {}; function startScanning() { // 设置一个间隔定时器每10秒执行一次扫描 setInterval(() { console.log(开始扫描...); NRF.findDevices({ timeout: 2000, // 扫描2秒 active: false, // 使用被动扫描省电 // filters: [{services:[180d]}] // 示例只扫描心率设备 }, function(devices) { let now Date.now(); devices.forEach(dev { // 如果设备没有名称用地址后几位代替显示 let name dev.name || (dev.id.substr(12,5)...); // 更新或创建缓存条目 deviceCache[dev.id] { name: name, rssi: dev.rssi, lastSeen: now, count: (deviceCache[dev.id] ? deviceCache[dev.id].count 1 : 1) // 被看到的次数 }; }); console.log(本次发现 ${devices.length} 个设备缓存中共有 ${Object.keys(deviceCache).length} 个唯一设备); // 扫描完成后触发UI更新 updateDisplay(); }); }, 10000); // 每10000毫秒10秒重复一次 } // 启动扫描 startScanning();这段代码做了几件事定义了deviceCache对象作为设备缓存。startScanning函数使用setInterval建立了一个每10秒执行一次的循环。每次循环中调用NRF.findDevices进行为期2秒的被动扫描。在回调函数中遍历所有发现的设备用设备地址id作为键将设备信息名称、RSSI、最后一次被发现的时间lastSeen、被看到的次数count存入deviceCache。最后调用updateDisplay()来刷新手表屏幕这个函数我们稍后实现。实操心得在实际测试中我发现被动扫描active: false对于大多数只需要探测存在的场景已经完全足够并且能显著提升续航。将扫描间隔设为10秒既不会错过移动缓慢的设备又给了蓝牙模块足够的休息时间。deviceCache使用对象存储以id为键确保了设备唯一性并且读取速度快。3.2 设备缓存老化与自动清理如果不加处理deviceCache会越来越大因为旧设备会一直留在里面。我们需要一个“垃圾回收”机制定期清理掉那些很久没出现的设备。function cleanupCache() { let now Date.now(); let oldThreshold 60000; // 60秒没出现就认为是旧设备 let keysToDelete []; for (let id in deviceCache) { if (now - deviceCache[id].lastSeen oldThreshold) { keysToDelete.push(id); } } if (keysToDelete.length 0) { keysToDelete.forEach(id { delete deviceCache[id]; }); console.log(清理了 ${keysToDelete.length} 个过期设备); updateDisplay(); // 清理后更新显示 } } // 可以每30秒执行一次清理 setInterval(cleanupCache, 30000);cleanupCache函数遍历缓存找出所有lastSeen时间距离现在超过oldThreshold这里设为60秒的设备将它们从deviceCache中删除。这个阈值可以根据场景调整在设备快速变化的区域可以设短一些如30秒在相对静态的环境可以设长一些如120秒。3.3 用户界面设计与渲染优化在小小的圆形屏幕上显示信息布局和渲染效率至关重要。Bangle.js提供了gGraphics对象来进行绘图。我们先实现一个简单的概览视图。function updateDisplay() { // 清空屏幕 g.clear(); // 设置字体和颜色 g.setFontAlign(0, -1); // 居中顶部对齐 g.setFont(6x8, 2); // 使用双倍大小的6x8字体 let totalDevices Object.keys(deviceCache).length; // 显示标题和设备总数 g.drawString(BLE探测器, g.getWidth()/2, 10); g.setFont(6x8, 1); // 切回正常大小 g.drawString(设备数: ${totalDevices}, g.getWidth()/2, 40); if (totalDevices 0) { // 找出信号最强的设备 let strongestDevice null; for (let id in deviceCache) { if (!strongestDevice || deviceCache[id].rssi strongestDevice.rssi) { strongestDevice {id: id, ...deviceCache[id]}; } } // 显示最强设备信息 g.drawString(最强信号:, g.getWidth()/2, 60); // 设备名可能很长需要截断 let displayName strongestDevice.name.length 12 ? strongestDevice.name.substr(0,12)... : strongestDevice.name; g.drawString(displayName, g.getWidth()/2, 75); g.drawString(RSSI: ${strongestDevice.rssi} dBm, g.getWidth()/2, 90); // 可以简单画一个信号强度条 let barWidth 100; let barX (g.getWidth() - barWidth) / 2; // RSSI范围假设从-100最弱到 -40最强映射到0到barWidth let strength Math.max(0, Math.min(1, (-strongestDevice.rssi - 40) / 60)); // 计算比例 let fillWidth Math.floor(barWidth * strength); g.drawRect(barX, 110, barXbarWidth, 120); // 外框 g.fillRect(barX, 110, barXfillWidth, 120); // 填充部分 } else { g.drawString(未发现设备, g.getWidth()/2, 70); } // 显示状态和提示 g.setFont(4x6); g.drawString(按中键切换视图, g.getWidth()/2, 150); // 刷新屏幕 g.flip(); }这个updateDisplay函数首先清屏然后显示总设备数和信号最强的设备详情并用一个简单的进度条可视化RSSI强度。它会在每次扫描完成或缓存清理后被调用。注意事项屏幕绘图是相对耗电的操作。频繁调用g.flip()全屏刷新会影响续航。因此我们只在数据确实发生变化时才更新显示。更高级的优化可以采用局部刷新但Bangle.js 2的默认图形库不支持对于这个应用目前的更新频率每10秒左右是可以接受的。3.4 多视图切换与交互实现只有一个概览视图不够用。我们需要实现通过手表按钮切换不同视图的功能比如一个详细的设备列表。let currentView overview; // overview 或 list // 绑定中键BTN2来切换视图 setWatch(function() { if (currentView overview) { currentView list; } else { currentView overview; } updateDisplay(); // 切换后立即重绘 }, BTN2, {edge:rising, repeat:true, debounce:50}); // 修改 updateDisplay 函数根据 currentView 渲染不同内容 function updateDisplay() { g.clear(); g.setFontAlign(0, -1); if (currentView overview) { // ... 渲染之前的概览视图代码 ... } else if (currentView list) { renderListView(); } // 显示当前视图提示 g.setFont(4x6); g.drawString(视图: ${currentView}, g.getWidth()/2, 150); g.flip(); } function renderListView() { g.setFont(6x8); g.drawString(设备列表, g.getWidth()/2, 5); let devicesArray Object.values(deviceCache); // 按RSSI从强到弱排序 devicesArray.sort((a, b) b.rssi - a.rssi); let startY 25; let lineHeight 20; let maxLines 7; // 屏幕能显示的行数 for (let i 0; i Math.min(devicesArray.length, maxLines); i) { let dev devicesArray[i]; let yPos startY i * lineHeight; // 显示名称和RSSI let displayText ${dev.name.substring(0,10)}: ${dev.rssi} dBm; g.drawString(displayText, 5, yPos); // 显示新鲜度秒 let ageSec Math.floor((Date.now() - dev.lastSeen) / 1000); g.drawString(${ageSec}s, g.getWidth() - 20, yPos); } if (devicesArray.length maxLines) { g.setFont(4x6); g.drawString(...还有${devicesArray.length - maxLines}个, g.getWidth()/2, 155); } }现在用户可以通过按手表中间按钮在“概览”和“列表”视图间切换。列表视图显示了所有设备的名称、RSSI和“几秒前被发现”的信息并按信号强度排序。实操心得在实现列表滚动时如果设备很多可以考虑使用上/下按钮来翻页。但考虑到手表操作便利性我选择只显示信号最强的几个设备并提示总数。这是一个在有限交互和完整信息之间的权衡。4. 高级功能与优化探索4.1 RSSI滤波与距离估算原始的RSSI值波动很大即使设备静止不动由于多径效应和环境干扰读数也会跳变。直接使用单次测量值来判断距离或移动非常不可靠。一个常见的改进是引入滑动窗口滤波。我们可以为缓存中的每个设备维护一个最近RSSI值的数组比如最近10次读数然后计算其平均值或中位数作为显示和判断用的“滤波后RSSI”。// 修改deviceCache的结构为每个设备增加rssiHistory数组 // deviceCache[dev.id] { // name: ..., // rssi: dev.rssi, // 最新值 // rssiHistory: [], // 历史值数组 // lastSeen: ..., // count: ... // }; // 在扫描回调中更新设备时 deviceCache[dev.id] { ...deviceCache[dev.id], // 保留原有属性 rssi: dev.rssi, rssiHistory: (deviceCache[dev.id] ? deviceCache[dev.id].rssiHistory : []), lastSeen: now }; // 将新值加入历史数组 deviceCache[dev.id].rssiHistory.push(dev.rssi); // 只保留最近10个值 if (deviceCache[dev.id].rssiHistory.length 10) { deviceCache[dev.id].rssiHistory.shift(); } // 计算滤波后的RSSI简单平均 let filteredRssi deviceCache[dev.id].rssiHistory.reduce((a,b)ab, 0) / deviceCache[dev.id].rssiHistory.length; deviceCache[dev.id].filteredRssi Math.round(filteredRssi);现在在UI显示时可以使用filteredRssi代替原始的rssi读数会稳定得多。关于距离估算理论上RSSI与距离成反比关系距离越远信号越弱遵循对数路径损耗模型。一个非常粗略的经验公式是距离 d ≈ 10 ^ ((TxPower - RSSI) / (10 * n))其中TxPower是设备在1米处的参考发射功率通常在广播数据中但很多设备不提供。RSSI是接收到的信号强度。n是路径损耗指数在复杂室内环境通常取2.5到4。由于TxPower常常未知且环境对n影响巨大所以用BLE RSSI做精确测距非常困难。但在相对距离比较和移动趋势判断上非常有用。例如观察某个设备的滤波后RSSI值持续增强可以推断它正在靠近反之则在远离。4.2 设备识别与分类除了显示地址和RSSI我们还可以尝试识别设备类型让显示信息更友好。BLE设备的广播数据包中可能包含制造商特定数据。我们可以根据制造商ID来识别一些常见品牌。// 一个简单的制造商ID映射表部分示例 const MANUFACTURERS { 0x004C: Apple, 0x006B: Samsung, 0x0098: Xiaomi, 0x015D: Philips, // ... 可以添加更多 }; function getManufacturerName(manufacturerId) { return MANUFACTURERS[manufacturerId] || 0x${manufacturerId.toString(16).toUpperCase()}; } // 在扫描回调中可以尝试解析制造商数据 devices.forEach(dev { let manufacturer Unknown; // dev.data 是ArrayBuffer需要解析 let ad dev.data; // 这是一个简化的解析实际需要按BLE广播数据格式完整解析 for (let i0; iad.length; ) { let len ad[i]; if (len 0) break; let type ad[i1]; if (type 0xFF) { // 制造商特定数据类型 let manId (ad[i3]8) | ad[i2]; // 小端格式 manufacturer getManufacturerName(manId); break; } i len 1; } // 将制造商信息也存入缓存 // ... });更进一步可以解析广播数据中的完整或短设备名、服务UUID列表。例如发现0x180D心率服务和0x180F电池服务UUID基本可以断定这是一个心率手环。这样就能在列表里显示“Apple Watch”、“小米手环”或“心率传感器”等更直观的名称。4.3 功耗优化与后台运行作为一个手表应用功耗是必须严肃考虑的问题。我们目前的实现每10秒扫描2秒已经比较省电但还有优化空间。动态调整扫描间隔如果长时间没有发现新设备或设备列表稳定可以逐步增加扫描间隔例如从10秒增加到30秒甚至60秒。当检测到RSSI剧烈变化或有新设备出现时再恢复高频扫描。利用Bangle.js的负载检测手表静止不动时比如放在桌上可以进入超低功耗模式暂停扫描或极大延长扫描间隔。Bangle.js的Bangle.isCharging()和加速度计读数可以辅助判断。后台运行与唤醒我们希望应用在息屏后也能偶尔扫描。这需要将应用设置为“后台”应用。在Espruino中可以通过监听Bangle.on(lcdPower, ...)事件来感知屏幕开关在息屏时切换到更省电的后台扫描模式比如每分钟扫描一次并在亮屏时恢复。注意后台运行的代码需要极其精简避免耗电操作。// 简单的后台模式示例 let isBackground false; Bangle.on(lcdPower, function(on) { isBackground !on; console.log(LCD power:, on, Background mode:, isBackground); if (isBackground) { // 进入后台停止当前扫描循环启动一个更慢的循环 clearInterval(scanInterval); startBackgroundScan(); } else { // 回到前台停止后台扫描恢复前台扫描 stopBackgroundScan(); startForegroundScan(); } });5. 调试技巧与常见问题排查5.1 如何调试BLE应用调试嵌入式设备上的JavaScript代码有其特殊性。Bangle.js 2提供了几种方式Web IDE控制台通过蓝牙或USB连接手表和Espruino Web IDE所有console.log的输出都会实时显示在IDE的控制台里。这是最直接、最重要的调试手段。在扫描回调中打印设备信息、缓存状态对于理解程序行为至关重要。屏幕输出在开发UI时可以直接在手表屏幕上打印调试信息比如用g.drawString画出来。虽然原始但在没有电脑连接时很管用。电量与内存监控在Web IDE中可以输入E.getBattery()查看电量process.memory()查看内存使用。长时间运行后检查这些值可以判断是否存在内存泄漏或异常耗电。使用NRF.getSecurityStatus()和NRF.getAddress()这些命令可以帮助你确认蓝牙模块的状态和手表自身的蓝牙地址。5.2 常见问题与解决方案问题现象可能原因排查步骤与解决方案扫描不到任何设备1. 蓝牙未开启。2. 手表与其他设备已连接占用了蓝牙射频。3. 周围确实没有BLE设备在广播。1. 检查NRF.getSecurityStatus()确保蓝牙已启用。2. 尝试断开手表与手机的连接如果已配对。3. 用手机上的BLE扫描App如nRF Connect验证环境。设备列表中出现大量“未知”或乱码地址这是正常的。许多BLE设备特别是苹果和较新的安卓设备会使用随机私有地址来广播以保护隐私。这些地址会周期性变化。无法避免。可以尝试通过设备广播数据中的制造商信息或服务UUID来辅助识别而不是依赖地址。在代码中可以将这类设备归类为“私有地址设备”。RSSI值跳动剧烈无线信号受环境影响大如人体遮挡、金属反射、Wi-Fi干扰等。1. 实施前面提到的滑动窗口滤波使用平均值或中位数。2. 确保手表佩戴在手腕外侧减少身体遮挡。3. 在相对开阔、干扰小的环境中测试。应用运行一段时间后变卡或崩溃1.内存泄漏deviceCache无限增长或存在未清理的闭包引用。2.内存碎片JavaScript对象频繁创建销毁。1. 确保cleanupCache函数被正确调用及时清理旧设备。2. 检查是否有全局变量或间隔定时器未被清理。使用setInterval时记得在应用卸载或模式切换时用clearInterval清理。3. 如果设备很多限制deviceCache的最大条目数如最多50个淘汰最旧的设备。手表耗电极快1. 扫描间隔太短或单次扫描时间太长。2. 屏幕刷新过于频繁。3. 应用在后台未正确进入低功耗模式。1. 优化扫描参数尝试增加扫描间隔减少单次扫描时长使用active: false。2. 仅在数据真正更新时刷新UI (updateDisplay)。3. 实现并测试后台低功耗逻辑确保息屏后扫描大幅减少或停止。NRF.findDevices回调不执行1. 前一次扫描尚未结束就开始了新的扫描。2. 蓝牙堆栈出现异常。1. 确保扫描是串行的即等上一次扫描回调完成后再启动下一次。可以用递归setTimeout代替setInterval来保证。2. 在极端情况下可以尝试软件复位蓝牙模块NRF.restart()但这会断开所有连接。5.3 一个更健壮的扫描循环实现为了避免扫描重叠和其他潜在问题下面是一个使用递归setTimeout的、更健壮的扫描循环示例let isScanning false; function performScan() { if (isScanning) { console.log(上次扫描还未结束跳过); scheduleNextScan(); return; } isScanning true; console.log([${Date().toString().substr(16,8)}] 开始扫描); NRF.findDevices({ timeout: 2000, active: false }, function(devices) { console.log(扫描结束发现 ${devices.length} 个广播); // 处理设备数据... processDevices(devices); isScanning false; // 安排下一次扫描 scheduleNextScan(); }); } function scheduleNextScan() { // 根据是否在前台/后台决定下一次扫描的延迟 let delay (isBackground ? 60000 : 10000); // 后台60秒前台10秒 setTimeout(performScan, delay); } // 启动第一次扫描 performScan();这个实现确保了同一时间只有一个扫描在进行并且扫描间隔是动态的适合与后台/前台模式配合使用。通过这个项目你将不仅仅得到一个BLE探测工具更深入地理解了BLE广播、扫描机制以及如何在资源受限的嵌入式设备上进行JavaScript开发、功耗优化和调试。你可以在此基础上继续扩展比如增加设备信号历史图表、接近报警功能当特定设备的RSSI强于阈值时震动甚至将扫描数据通过蓝牙同步到手机进行更复杂的分析。Bangle.js 2这个小巧的平台为无线物联网探索提供了无限可能。