1. 为什么在这一讲选择了蓝牙 beacon 测距,而不是其他定位技术
接着上一讲的话题往下说。我们这套“联网篇”系列,前面几讲讲的是ESP32怎么接入网络、怎么上报数据、怎么做OTA升级,那些本质上都是设备与服务器之间的通信。但到了这一讲,方向会有一个明显的变化:ESP32不仅要“上网”,还要“感知周围的环境”。蓝牙beacon测距,就是让ESP32具备空间感知能力的一种非常务实的方案。
先说清楚一个很容易产生的误区:很多人听到“beacon测距”,第一反应是“这不就是室内定位吗?GPS不香吗?”但实际做过项目的同学都知道,GPS在室内几乎是废的,民用级定位精度在三到十米左右波动,放到商场、地下车库、办公楼里直接变成摆设。而蓝牙beacon方案,在理想环境下能把测距误差控制在1到3米范围内,虽然做不到UWB那种厘米级,但胜在成本极低、部署灵活、手机和微控制器生态都兼容。
从应用场景来看,beacon测距能做的事情远比想象中多。比如商场里做客流统计和柜台的近场推送,比如养老院里的老人防走失预警,比如仓库里的资产盘点与区域判定,又比如我最近在做的一个小项目——用ESP32充当一个“智能门锁联动器”,根据佩戴的beacon标签与门的距离来判断是否自动解锁。这些场景的共同点在于:“设备在哪儿、离我多远”不是绝对坐标问题,而是一个相对的近场判定问题,而beacon测距恰恰就是这类问题的最优解。
所以这一讲的核心目标很明确:在VS Code环境下,基于ESP-IDF框架,让ESP32通过扫描蓝牙beacon广播包,解析RSSI信号强度,并通过一套经过校准的距离模型把信号强度换算成物理距离,最终在日志里稳定地输出“目标大约多远”的实时数据。让我先把这条技术路线的整体框架画个谱,再一步步带大家落地。
如果你现在手上已经有ESP32开发板和一块普通的蓝牙beacon设备(或者用手机装一个beacon广播App代替),那么这篇文章你可以从头到尾跟着做一遍。如果你暂时没有硬件,也没关系,代码逻辑和解析思路也是完全通用的,等你拿到硬件照着抄一遍基本就能跑通。
2. 先弄懂两个关键概念:iBeacon 广播包和 RSSI 信号强度
在写代码之前,我建议你花十分钟把iBeacon和RSSI这两个最基础的概念消化掉。很多人在这个环节上没重视,导致后面调参数的时候完全不知道在调什么。
2.1 iBeacon 广播包到底是什么样的数据
iBeacon是苹果2013年推出的一种基于BLE广播协议的消息格式,但它并不是一个什么高深莫测的协议,本质上就是在BLE的广播信道里塞了一段特定结构的数据。
一个标准的iBeacon广播包,数据部分由以下几段组成:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 苹果公司ID(Company ID) | 2字节 | 固定为0x004C,表示Apple |
| iBeacon类型 | 1字节 | 固定为0x02 |
| iBeacon长度 | 1字节 | 固定为0x15,即剩余21字节 |
| UUID | 16字节 | 用于区分一个beacon的归属,比如同一家连锁店的设备共享一个UUID |
| Major | 2字节 | 用于进一步分组,比如同一UUID下按门店区分 |
| Minor | 2字节 | 用于标识具体设备,比如门店里的第几号beacon |
| Tx Power | 1字节 | 表示距离接收端1米处的参考RSSI值,通常用负数表示,比如-59dBm |
其中UUID、Major、Minor三个字段组合起来,就可以唯一确定一个beacon设备。Tx Power字段是整个测距逻辑里的灵魂——它告诉接收方“如果你在离我1米的地方测我的信号强度,你会测到这么多dBm”,这个值在出厂时由beacon厂商标定,一般是-59、-65、-69之类的经验值。
还有一个细节需要了解:iBeacon广播包必须包含在BLE广播报文的ADV_IND(可连接无定向广播)或ADV_NONCONN_IND(不可连接无定向广播)里,广播间隔通常在100ms到1000ms之间。广播间隔越短,定位刷新越快,但功耗越高,实际做项目时要在这两者之间取平衡。
2.2 RSSI:天线口收到的信号能量,而不是一个稳定的物理量
RSSI(Received Signal Strength Indicator)的中文叫“接收信号强度指示”,单位是dBm,是一个表示功率的绝对值。它的物理意义是:接收端天线上感应到的射频能量。理论上,距离越近,RSSI越大(越接近0),距离越远,RSSI越小(越负)。
但RSSI这个值非常“喜怒无常”。它容易受到以下因素的干扰:
- 路径损耗:信号在空气中传播,能量随距离呈对数衰减,这是最核心的关系。
- 反射和多径效应:室内环境里,信号会撞到墙壁、地板、金属物体反复反射,接收端收到的是直达信号和反射信号叠加后的结果,导致RSSI在某些位置出现跳变。
- 天线方向性:ESP32板载天线是有指向性的,beacon设备本身的朝向也会影响辐射方向图。
- 人体遮挡:如果有一个人站在发射端和接收端之间,人体含水量高,对2.4GHz信号的吸收非常明显,RSSI可能瞬间掉十几个dBm。
明白了这些,你就知道为什么beacon测距不能像超声波测距那样直接算时间差,因为它本身就是个用“信号衰减程度”反推距离的统计性方案。所以我们必须引入一个数学模型来拟合这种关系。
2.3 为什么说Tx Power是测距的“参照锚点”
把上一小节的RSSI和iBeacon包里的Tx Power放在一起看,就能得到最经典的距离拟合公式:
$$d = 10^{(P_{tx} - RSSI) / (10 * n)}$$
其中,$P_{tx}$就是iBeacon包里的Tx Power(1米参考RSSI,通常取负值),$RSSI$是当前接收到的信号强度,$n$是路径损耗指数(环境衰减因子)。
这个公式看起来简单,但工程上要落地并不容易,核心难点在于$n$的取值。在开阔的室外空间,$n$大约等于2;在普通室内办公室,$n$通常在2.5到3.5之间;如果是在走廊、仓库这种多反射环境,$n$可能直接飙到4以上。$n$取错了,两三米之外的数据就完全没法看。
所以我在后面专门开一小节讲标定,这里先把这个公式吃透,后面写代码的时候你的思路会清晰很多。
3. 在ESP-IDF里启用BLE扫描:从初始化到回掉函数的完整流程
搞清楚了原理,现在进入动手环节。这一讲用的ESP32芯片是经典的ESP32-WROOM-32开发板,BLE部分走的是NimBLE协议栈,而不是默认的Bluedroid。关于协议栈的选型,我多说两句。
3.1 为什么我用NimBLE而不是Bluedroid
ESP-IDF同时支持Bluedroid和NimBLE两套BLE协议栈。Bluedroid是传统方案,功能全,兼容性好,但有个硬伤——吃内存非常多,动辄几十KB的堆内存被吃掉,而且初始化慢,代码结构也偏重。NimBLE是Apache MyNewT项目下的轻量级BLE协议栈,专为资源受限的嵌入式设备设计,代码量小、内存占用少、启动快。对于beacon扫描这种不太需要复杂GATT服务的应用,NimBLE完全够用,而且省下来的内存还能让我们跑点别的逻辑。
你可能会问:“我用默认的Bluedroid不也照样能扫?”能,但我实测下来,NimBLE在扫描响应速度和稳定性上明显更好,尤其在连续扫描7×24小时跑了两天之后,NimBLE没有出现过一次协议栈崩溃。所以这门课里后续所有蓝牙相关的例子,我都统一用NimBLE。
开启NimBLE的方法是在menuconfig里配置:
- 在VS Code中打开项目,按快捷键
F1,输入ESP-IDF: SDK Configuration Editor进入menuconfig图形配置界面; - 依次进入
Component config > Bluetooth > Bluetooth,使能蓝牙总开关; - 选择
Bluetooth controller为Enabled; - 在
Host选项中选择NimBLE。
如果没法打开menuconfig,也可以直接在项目的sdkconfig文件里手动修改,我建议初学者还是用图形化界面操作,不容易出错。
3.2 扫描配置的几个关键参数
NimBLE的扫描参数主要由ble_gap_disc_params_t结构体定义,核心字段有这几个:
struct ble_gap_disc_params_t disc_params = { .filter_duplicates = 1, // 过滤重复广播包:同一设备只上报一次 .passive = 0, // 0=主动扫描,1=被动扫描 .limited = 0, // 设为0,扫描所有广播类型 .itvl = 0x50, // 扫描间隔,单位625us,0x50=50ms .window = 0x30, // 扫描窗口,单位625us,0x30=30ms };这里最值得解释的是扫描间隔(itvl)和扫描窗口(window)这组参数。BLE扫描器不是全时在听的,它会清醒一段时间(扫描窗口),然后睡一段时间(扫描间隔),如此循环。窗口占间隔的比例叫“占空比”。占空比越高,扫描越密集,发现beacon的速度越快,但功耗也线性上升。实测经验:
- 开发调试阶段:间隔50ms,窗口30ms,反应灵敏,功耗可忽略。
- 低功耗部署:间隔200ms,窗口20ms,beacon广播间隔如果也是200ms,可能会偶尔漏包。
- 稳妥方案:间隔100ms,窗口50ms,兼顾灵敏度和功耗。
这个设置在后面的测距频率里也很关键——扫描窗口拉得越长,单次扫描稳定获取到的RSSI样本就越多,取平均之后的值越有代表性,测距也就越稳。
3.3 扫描回调函数怎么写
NimBLE的核心事件驱动模型里,我们需要注册一个GAP事件回调函数。当扫描到周围的BLE设备时,协议栈会触发BLE_GAP_EVENT_DISC事件,我们在回调里解析事件数据就行。
#include "esp_log.h" #include "nimble/nimble_port.h" #include "nimble/nimble_port_freertos.h" #include "host/ble_hs.h" #include "host/util/util.h" static const char *TAG = "BLE_BEACON"; static int scan_count = 0; static int beacon_scan_event_handler(struct ble_gap_event *event, void *arg) { switch (event->type) { case BLE_GAP_EVENT_DISC: // 扫描到一个新设备,进入解析流程 scan_count++; ESP_LOGI(TAG, "抓到广播包 #%d, rssi=%d", scan_count, event->disc.rssi); // 这里后续会调用解析函数 parse_ibeacon(event); break; case BLE_GAP_EVENT_DISC_COMPLETE: // 一轮扫描结束,重新发起下一轮 ble_gap_disc(0, &disc_params, beacon_scan_event_handler, NULL); break; default: break; } return 0; }注意一个细节:BLE_GAP_EVENT_DISC_COMPLETE事件发生说明一轮扫描周期结束,此时需要重新调用ble_gap_disc()发起新一轮扫描,否则扫描会停在那里不动。很多新手第一次写NimBLE扫描程序,发现打印了几条数据之后就没反应了,就是漏了这个重发扫描的逻辑。
还有一个常见坑:如果你不处理BLE_GAP_EVENT_DISC_COMPLETE,而只是反复调用ble_gap_disc来重启扫描,协议栈有时会返回BLE_HS_EALREADY错误,表示扫描通道还没关闭。正确做法就是等这个COMPLETE事件回调里再发起新一轮扫描,不要用定时器强行打断。
3.4 主程序框架的初始化步骤
NimBLE的初始化流程比较固定,一般是:
void ble_host_task(void *param) { nimble_port_run(); // 启动NimBLE主机任务,不会返回 } void app_main(void) { esp_err_t ret; // 1. 初始化NVS,NimBLE需要用它来存储配置 ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); nvs_flash_init(); } // 2. 初始化NimBLE主机协议栈 nimble_port_init(); // 3. 配置并启动GAP ble_svc_gap_device_name_set("ESP32-BeaconScanner"); ble_gap_disc(0, &disc_params, beacon_scan_event_handler, NULL); // 4. 创建NimBLE主机任务 nimble_port_freertos_init(ble_host_task); // 5. 主任务可以干别的事情了,比如读取传感器、上报数据等 }这里有一个容易踩的坑:nimble_port_init()和nimble_port_freertos_init()必须配对使用,前者负责底层初始化,后者创建协议栈的运行任务。如果忘了后者,整个协议栈不会跑起来,并且扫描回调永远不会触发。
4. iBeacon数据解析:从原始广播帧里把UUID、Major、Minor、Tx Power抠出来
扫描事件event->disc结构体里实际上给了我们两样最重要的东西:event->disc.rssi是当前接收到这个广播包时的信号强度,event->disc.adv_data是完整的广播数据缓存。beacon解析要做的就是从这个字节缓存里定位iBeacon数据段。
4.1 AD Structure的格式套路
BLE广播数据的格式是一种“TLV三段式”(Type-Length-Value)。每一段的结构是:
- 第1个字节:Length,表示后面数据的总字节数(包含Type和Data)。
- 第2个字节:Type,表示数据的类型代码。
- 第N个字节:Data,实际的数据内容。
其中Type有非常多的代码定义:0x01表示Flag,0x02/0x03表示16位Service UUID列表,0xFF表示Manufacturer Specific Data(厂商自定义数据),iBeacon就是栽在这棵0xFF的树上的。
所以解析思路就是:遍历广播数据里的每一条AD Structure,找到Type为0xFF的那一条,然后检查它的前两个字节是不是0x004C和0x0215。
4.2 解析函数的完整实现
#include "host/ble_hs.h" #define IBEACON_AD_TYPE 0xFF #define IBEACON_MANUFACTURER_ID 0x004C #define IBEACON_DATA_TYPE 0x02 #define IBEACON_DATA_LEN 0x15 typedef struct { uint8_t uuid[16]; uint16_t major; uint16_t minor; int8_t tx_power; int8_t rssi; } ibeacon_info_t; static void parse_ibeacon(struct ble_gap_event *event) { struct os_mbuf *om = &event->disc.adv_data; uint8_t *data = om->om_data; int len = om->om_len; int index = 0; while (index < len) { uint8_t field_len = data[index]; if (field_len == 0) break; uint8_t field_type = data[index + 1]; // 只关心Manufacturer Specific Data if (field_type == IBEACON_AD_TYPE && field_len >= 25) { uint8_t *p = &data[index + 2]; uint16_t company_id = (p[1] << 8) | p[0]; if (company_id == IBEACON_MANUFACTURER_ID && p[2] == IBEACON_DATA_TYPE && p[3] == IBEACON_DATA_LEN) { ibeacon_info_t beacon; memcpy(beacon.uuid, &p[4], 16); beacon.major = (p[20] << 8) | p[21]; beacon.minor = (p[22] << 8) | p[23]; beacon.tx_power = (int8_t)p[24]; beacon.rssi = event->disc.rssi; print_beacon(&beacon); return; } } // 跳到下一条AD Structure:1字节长度 + Length字节数据 index += field_len + 1; } }这段代码的关键点在index += field_len + 1这句,它让我们能按TLV的结构逐条遍历。忘了这一步,或者索引算错一位,都可能把同一个包解析出幻数来的数据。实际现象往往是:输出乱码、UUID完全不对、RSSI看起来正常但major/minor全是0,一查全是索引问题。
4.3 大端小端别搞反了
iBeacon协议明确规定,Major和Minor字段是网络字节序,也就是高字节在前(大端)。而ESP32内部是标准的小端模式,所以解析时必须手动做位移拼接:
beacon.major = (p[20] << 8) | p[21];如果直接memcpy,或者用*(uint16_t*)p这样粗暴的方式解析,你得到的结果在高位和低位上是错位的。举个具体例子,如果beacon发的是Major=0x0102,小端非法读取会得到0x0201,也就是513变成258。
当然后面如果你用手机App来模拟beacon发送,很多App在界面上显示为大端数字,但底层抓包工具出来的字节序也遵循大端,所以这个细节一定要在代码里处理对,不然联调的时候就会对不上。
4.4 打印与筛选
解析出UUID、Major、Minor之后,可以按需决定是全部打印,还是只筛选某个UUID的设备。如果测试现场有其他手机、耳机、遥控器在广播,打印会非常乱。我一般会建议先加一层筛选:
static uint8_t target_uuid[16] = { 0xE2, 0x0A, 0x39, 0xF4, 0x73, 0xF5, 0x4B, 0xC4, 0xA1, 0x2F, 0x17, 0xD1, 0xAD, 0x07, 0xA9, 0x61 }; if (memcmp(beacon.uuid, target_uuid, 16) != 0) { return; // 不是目标beacon,直接丢弃 }筛选逻辑带来的另一个附加好处是:日志干净了,调试的时候大脑不用在一堆无关设备里翻找有用信息,定位问题会快很多。
5. 把RSSI换算成距离:两种测距模型与工程校准方法
拿到RSSI之后,最核心的问题就是怎么把dBm数变成米数。这里我来讲两种最常用的模型,一种是“经验参数模型”,另一种是“多点标定最小二乘模型”。实际项目中我通常会两种方法结合着用。
5.1 经验参数模型:一个公式打天下
前面提到过,最常用的模型是信号传播对数衰减模型:
$$d = 10^{(P_{tx} - RSSI) / (10 * n)}$$
对应到C代码:
#include <math.h> static double rssi_to_distance(int8_t rssi, int8_t tx_power, float n) { if (rssi == 0) return -1.0; // 无效信号 double exponent = (double)(tx_power - rssi) / (10.0 * n); return pow(10.0, exponent); }关键参数说明:
- $P_{tx}$:iBeacon包里的Tx Power,典型值在-55dBm到-70dBm之间。不同厂商的beacon标定值不一样,你直接拿手机站在1米处,用Beacon Scanner这类App测出来的那个RSSI就是要填入的$P_{tx}$。当然,也更精确的做法是把这个值写死在程序里,同时允许通过配置动态修正。
- $n$:路径损耗指数,取决于环境。经验值参考表格如下:
| 环境 | n取值建议 |
|---|---|
| 开阔室外 | 2.0 |
| 普通办公室 | 2.5-3.0 |
| 室内隔间较多 | 3.0-3.5 |
| 仓库/金属货架 | 3.5-4.5 |
| 走廊/多反射 | 3.0-4.0 |
实测中,如果你用的是普通办公桌环境,先填2.8基本八九不离十,后面再通过实测微调。
5.2 多点标定最小二乘模型:让数据自己说话
经验模型的优点是简单直观,缺点是$n$这个参数是拍脑袋定的,如果环境复杂,误差会很大。所以我更推荐做一次“小规模标定实验”,用实测数据反推模型参数。
原理是这样的:我们对数衰减公式两边取对数,可以把非线性问题转成线性回归。把公式变形成:
$$RSSI = P_{tx} - 10 * n * log_{10}(d)$$
把$P_{tx}$和$-10n$看成两个待求参数。在标定实验里,我们在已知距离$d_1, d_2, ..., d_k$处分别测量RSSI得到$R_1, R_2, ..., R_k$,然后用最小二乘法拟合出斜率$b$和截距$a$,于是得到:
$$P_{tx} = a, \quad n = -b / 10$$
在PC上用Python做个简单的线性回归也就是十几行的事,但前提是你现场的采样数据要对齐。我习惯在每个距离点上采集至少20个RSSI样本,去掉最高最低各5个,取剩下的平均值,这样能有效减少偶然抖动。
实际标定的步骤我整理成下面这段:
- 按0.5米、1米、2米、3米、5米、8米六个距离点测量RSSI,距离用激光测距仪或者卷尺量准,尽量减少人为误差;
- 每个点放好beacon之后等待20秒,让接收端稳定下来,再采集样本;
- 把采集到的原始数据通过串口导出,用脚本来拟合;
- 如果拟合结果出来之后某个距离点的残差特别大,回到现场看看这个位置是不是有金属物或者墙角反射,考虑调整采样位置或者增加数据点。
5.3 抖动处理:为什么你必须做滑动平均
RSSI天生抖动大,如果不做任何滤波,你会看到距离数值在0.5米到3米之间乱跳。平滑处理是必须的。
我常用的是一种简单的滑动平均滤波:
#define RSSI_HISTORY_SIZE 10 static int8_t rssi_history[RSSI_HISTORY_SIZE]; static int rssi_index = 0; static int rssi_count = 0; void rssi_filter_push(int8_t rssi) { rssi_history[rssi_index] = rssi; rssi_index = (rssi_index + 1) % RSSI_HISTORY_SIZE; if (rssi_count < RSSI_HISTORY_SIZE) { rssi_count++; } } int8_t rssi_filter_get_average(void) { int sum = 0; for (int i = 0; i < rssi_count; i++) { sum += rssi_history[i]; } return (sum / rssi_count); }滑动窗口取多少合适?太短(比如3个)效果不明显,太长(比如30个)虽然平滑但响应严重滞后,beacon移动起来之后测距数据半天变不过来。我测试下来10个是感知和稳定性的较好平衡点。
当然,比这更强的还有卡尔曼滤波和加权移动平均,但滑动平均已经能解决大多数问题,我先把这个基础版本讲透,后面有必要再单独开一篇来讲卡尔曼滤波在RSSI去抖中的应用。
6. 完整工程的编译烧录与VS Code调试心得
前面讲了很多代码片段和原理,这一节把整个工程从创建到最终跑通串起来,顺便分享几个在VS Code里用ESP-IDF开发时我亲测好用的工作流。
6.1 用VS Code的ESP-IDF扩展创建工程
如果你已经装好了Espressif IDF插件(官网上有详细的安装教程,这里不展开),创建工程的过程非常简单:
- 在VS Code里按
F1,输入ESP-IDF: Show Examples Projects,这会列出官方所有示例工程; - 我们不需要从头新建,直接基于
bluetooth/nimble路径下的ble_ht示例复制一份,这个示例工程自带了NimBLE协议栈的初始化框架,改起来最省事; - 选择保存目录,点击
Create project using example,然后会自动加载并编译。
之所以推荐从这个示例改,是因为NimBLE的初始化部分对新手来说比较绕,模板工程里已经把nimble_port_init、ble_hs初始化、主机任务创建这些流程串好了。你在它基础上替换GAP扫描相关的代码,比自己从零搭建快得多,也不容易漏配置。
6.2 编译烧录串口监视的一键操作
工程有了之后,编译和烧录这块其实是ESP-IDF插件最顺手的部分。界面右上角的下拉菜单里有几个常用命令,建议右键把它们加到收藏:
ESP-IDF: Build your project:编译工程,快捷键是Ctrl+E, B。编译输出会在“output”面板里显示,错误定位还可以直接点击跳转到源码位置。ESP-IDF: Flash your project:烧录到设备,内部会自动调用esptool.py,需要提前选好串口(插件会自动识别,或者手动指定)。ESP-IDF: Monitor your device:打开串口监视器,配合日志可以看到printf/ESP_LOGI输出的数据。
这三个命令组合起来就是我的标准开发循环:改代码 -> 编译 -> 烧录 -> 监视。每次改完代码,按一个快捷键就能完成编译烧录,基本用不上命令行,效率非常高。
6.3 一个比较隐蔽的坑:Flash下载速度
我遇到过好几次这样的情况:代码逻辑完全没问题,但烧录的时候频繁提示A fatal error occurred: Failed to connect to ESP32。排查到最后,发现是烧录串口默认的波特率太高(921600),加上USB转串口的线质量一般,导致了通信不稳定。
解决办法是在menuconfig里把下载波特率降到115200或230400(路径:Serial flasher config > Default flash baud rate),或者在烧录命令的idf.py -b 115200 flash里显式指定。如果你是用的廉价开发板,这一步基本算标配操作。
还有一个相关的小经验:ESP32的EN按钮是复位,BOOT按钮是下载模式。如果进入下载时偶尔失败,按住BOOT不放、快速按一下EN复位,再松开BOOT再点烧录,成功率会提升很多。
6.4 日志排查的最佳实践
beacon扫描跑起来之后,日志输出可以直接用ESP_LOGI来打。我的习惯是给每一条beacon输出打上一个固定的格式化模板,方便用串口调试工具或者脚本做后续分析。
ESP_LOGI(TAG, "beacon uuid=%02x%02x... major=%d minor=%d tx_power=%d rssi=%d dist=%.2fm", beacon.uuid[0], beacon.uuid[1], beacon.major, beacon.minor, beacon.tx_power, beacon.rssi, distance);后面如果你想把这套标签数据推到服务器或者手机端做可视化,统一格式的日志也可以直接作为数据源,免去二次解析的麻烦。
7. 实测数据、误差分析与后续扩展方向
代码全部跑通之后,我建议你不急着上复杂算法,先花一个小时做一个系统的实测,把测距的表现摸清楚。下面是我在一间普通办公室里的测试记录,给你作为参考对照。
7.1 一组真实测试数据对比
测试环境:普通办公隔间,桌面高度约75cm,周围有显示器、键盘、文件柜等常见杂物。beacon使用某厂商的塑料外壳扭扣iBeacon,广播间隔500ms,Tx Power标称-59dBm。
| 实际距离 | 单次RSSI | 滤波后RSSI | 计算距离 | 误差 |
|---|---|---|---|---|
| 0.5米 | -51 | -52 | 0.58米 | 0.08米 |
| 1米 | -59 | -59 | 1.00米 | 0米 |
| 2米 | -65 | -66 | 1.96米 | 0.04米 |
| 3米 | -71 | -72 | 3.29米 | 0.29米 |
| 5米 | -78 | -80 | 6.15米 | 1.15米 |
| 8米 | -85 | -86 | 9.70米 | 1.70米 |
可以看到,近距离(1到3米)的测距表现相当不错,误差控制在0.3米以内,但距离拉远之后误差越来越大。这背后的原因在于RSSI信号随距离的衰减越来越平缓,接收端微小的RSSI波动反映到距离上就被放大了好几倍。
如果遇到远距离误差过大的情况,有一个折中策略可以参考:设定一个“可信测距范围”,比如3米以内用计算距离,3米以外就输出“较远”的离散化状态。很多近场应用其实只需要这种模糊化的距离等级,不需要连续精确的距离值。
7.2 从单个beacon测距扩展成“区域判定”与“定位”
测距能力本身只是第一步,它的真正价值在于和具体业务结合起来。
一个很实用的方向是区域判定。比如你布置了两个beacon,一个放在桌子A附近,一个放在门口附近。ESP32扫描到哪个beacon的RSSI更强、距离更近,就判定当前“谁离哪个位置更近”。这样做的好处是你的系统不用集中到服务器算,边缘端就能直接决策,适用于门禁、考勤、设备联动这些场景。
另一个方向是三角形质心定位:用三个beacon的测距值画三个圆,取交集中心点来估计ESP32的二维坐标。这在理论上是可行的,但由于RSSI波动较大,实际精度通常只能做到2米左右的区域范围,想拿来做精确导航不现实,做人员到岗检测、物品区域管理倒是够用。
更进一步,你可以把这套测距数据上传到MQTT或者HTTP服务器,用云端算法去处理多设备融合定位。ESP32在这里的角色就变成了“传感器节点”,只负责采集RSSI并上报,复杂的定位和联动交给上层平台来完成。这种架构灵活性最高,后续要加beacon节点或者换算法,都不需要改动下位机固件。
7.3 功耗与扫描频率的长期运行注意点
如果你的设备是电池供电的长期部署,扫描占空比和运行时间就变得非常关键。
我在代码里做的优化是:定义了一个“扫描-休眠”模式,每轮扫描10秒,然后进入深度睡眠20秒,用定时唤醒来实现周期性扫描。这样平均电流能压到几十毫安以下,用18650电池或者锂电池供电可以跑很长时间。
还有一个容易忽略的地方:beacon设备本身的广播间隔会影响功耗和刷新率的平衡。beacon广播间隔越长,beacon端越省电,但接收端扫描到它的概率就会下降。我的实际测试结果是,广播间隔500ms在扫描占空比50%的情况下,大约需要1到2秒才能稳定捕获一个beacon,这个延时对大多数场景是可接受的。
如果你希望更快响应,可以把广播间隔调到200ms以内,但beacon的电池续航会明显缩短——一颗CR2477电池从大约一年多降到了半年左右,具体数值要看制造商的数据手册。
7.4 最后分享一个我改装过的工程小技巧
最后说一个我自己的习惯,不一定适合所有人,但实测确实能省很多事:在工程里加一个beacon_config.h头文件,把目标UUID、Tx Power校准值、环境衰减因子n、滑动平均窗口长度、以及扫描参数全部集中定义在里面。
#pragma once #define TARGET_UUID_0 0xE2 #define TARGET_UUID_1 0x0A // ... 16个字节逐个定义 #define DEFAULT_TX_POWER (-59) #define ENV_ATTENUATION_N 2.8f #define RSSI_HISTORY_SIZE 10 #define SCAN_INTERVAL_MS 100 #define SCAN_WINDOW_MS 50我把这些参数集中在头文件之后,每次到现场调参只需要改这一个文件,重新编译烧录,不到一分钟就能完成一次改动。配合我上面说的串口日志模板,现场快速标定和参数调优的效率提升了不止一倍。
实际把多套参数轮换跑下来,你会慢慢发现自己所在的典型环境里,那套参数组合最靠谱。这个落在纸面上的经验值,才是最宝贵的工程资产。希望这篇讲完,你也能总结出一套属于自己的蓝牙beacon测距调试方法论。