毕业设计蓝牙定位实战:从 RSSI 测距到室内定位系统搭建
很多 IoT 方向的毕业设计都会把“蓝牙室内定位”当选题,听起来门槛不高,真动手才发现 RSSI 像坐过山车——同一点一分钟内能差 10 dB。本文把我在实验室熬过的坑整理成一份可落地的“小系统”笔记,目标只有一个:让同选题的你两周内跑出能看的坐标,而不是在走廊里来回踱步怀疑人生。
一、背景痛点:RSSI 为什么不靠谱
信号波动大
2.4 GHz 频段拥挤,Wi-Fi、微波炉、USB3.0 都在凑热闹;人体遮挡就能让 RSSI 掉 6 dB。设备异构
安卓 10 以后扫描返回的txPower字段常被厂商填 0,iPhone 干脆不开放原始 RSSI,导致同一段代码在不同手机上报的距离能差一倍。多径效应
实验室走廊长 30 m,金属门、玻璃墙来回反射,多径时延 < 400 ns,人眼看不出来,但 RSSI 会被“叠加”得忽高忽低。采样粒度
很多同学把startScan()周期设 100 ms,结果后台缓存没清空,同一包被重复上报,画出来的轨迹像蜘蛛网。
二、技术选型:BLE vs Wi-Fi vs UWB
| 指标 | BLE | Wi-Fi (RTT) | UWB |
|---|---|---|---|
| 硬件成本 | ¥15/信标 | ¥60/节点 | ¥200+/节点 |
| 定位精度 | 2–3 m(滤波后) | 1–2 m | 10 cm |
| 功耗 | 硬币电池 6 个月 | 需持续关联 AP | 瞬时高,需充电 |
| 手机兼容 | 全平台 | Android 9+ | 极少手机内置 |
| 开发量 | 最小 | 中 | 最大 |
结论:毕业设计周期 3 个月、预算 < ¥500,BLE 是最能写出完整故事的选择;UWB 适合论文冲“高精度”,但硬件预算和调试时间直接翻倍。
三、系统架构速览
- 信标节点:nRF52832 模块,100 ms 周期发 iBeacon 帧,发射功率 0 dBm。
- 接收端:ESP32 做扫描网关,通过 MQTT 把原始 RSSI 发上位机。
- 上位机:Python 跑滤波 + 三边定位,可视化用 PyQtGraph,刷新 5 Hz。
- 校准层:首次启动时提示用户把信标放地上,走“之”字形采集 50 点,自动拟合环境因子 n 与参考 RSSI。
四、核心实现:代码级拆解
以下示例全部在 GitHub 开源,文末附地址。这里只贴关键段,保证能直接跑通。
4.1 ESP32 扫描网关(Arduino)
#include <BLEDevice.h> #include <WiFi.h> #include <PubSubClient.h> const char* mqtt_server = "192.168.31.99"; const int scan_window_ms = 800; const int report_every = 3; // 每 3 次扫描上报一次,省流量 WiFiClient espClient; PubSubClient mqtt(espClient); class AdvertisedCallback : public BLEAdvertisedDeviceCallbacks { void onResult(BLEAdvertisedDevice& ad){ if(strlen(ad.getName().c_str())==0) return; // 只收命名过的信标 String mac = ad.getAddress().toString().c_str(); int rssi = ad.getRSSI(); char buf[128]; snprintf(buf, sizeof(buf), "{\"mac\":\"%s\",\"rssi\":%d}", mac.c_str(), rssi); mqtt.publish("ble/raw", buf); } }; void setup(){ Serial.begin(115200); WiFi.begin("lab_wifi", "12345678"); while (WiFi.status() != WL_CONNECTED) delay(500); mqtt.setServer(mqtt_server, 1883); BLEDevice::init(""); BLEScan* pBLEScan = BLEDevice::getScan(); pBLE_scan->setAdvertisedDeviceCallbacks(new AdvertisedCallback()); pBLE_scan->setActiveScan(false); // 被动扫,省电 pBLE_scan->setInterval(80); 80*0.625=50 ms pBLE_scan->setWindow(40); 40*0.625=25 ms } void loop(){ if (!mqtt.connected()) reconnect_mqtt(); mqtt.loop(); BLEScanResults found = pBLEScan->start(scan_window_ms/10, false); pBLEScan->clearResults(); static int cnt = 0; if(++cnt >= report_every) cnt = 0; }要点:
- 被动扫描比主动扫描功耗降 30%,丢包率却无明显上升。
- 把
setWindow设成setInterval的一半,保证芯片有 50% 时间休眠。
4.2 Python 端:卡尔曼滤波 + 三边定位
import paho.mqtt.client as mqtt import numpy as np from filterpy.kalman import KalmanFilter import json, math, time # 环境因子,先写死,后面自动校准 N = 2.8 RSSI_1M = -59 def rssi_to_dist(rssi): return 10 ** ((RSSI_1M - rssi)/(10 * N)) class Beacon: def __init__(self, mac, x, y): self.mac = mac self.x, self.y = x, y self.kf = KalmanFilter(dim_x=1, dim_z=1) self.kf.x = np.array([0.]) self.kf.F = np.eye(1) self.kf.H = np.eye(1) self.kf.P *= 10. self.kf.R = 8 # 观测噪声 self.last_update = time.time() def update(self, rssi): self.kf.predict() self.kf.update(np.array([rssi])) self.last_update = time.time() def get_smoothed_rssi(self): return self.kf.x[0] beacons = { "aa:bb:cc:dd:01": Beacon("aa:bb:cc:dd:01", 0, 0), "aa:bb:cc:dd:02": Beacon("aa:bb:cc:dd:02", 6, 0), "aa:bb:cc:dd:03": Beacon("aa:bb:cc:dd:03", 0, 6), } def trilaterate(dist_vec): (x1,y1,d1),(x2,y2,d2),(x3,y3,d3) = dist_vec A = 2*np.array([[x2-x1, y2-y1], [x3-x1, y3-y1]]) b = np.array([d1**2 - d2**2 + x2**2 - x1**2 + y2**2 - y1**2, d1**2 - d3**2 + x3**2 - x1**2 + y3**2 - y1**2]) try: return np.linalg.solve(A, b) except np.linalg.LinAlgError: return None def on_message(client, userdata, msg): try: j = json.loads(msg.payload) mac, rssi = j['mac'], int(j['rssi']) if mac not in beacons: return beacons[mac].update(rssi) except: return client = mqtt.Client() client.on_message = on_message client.connect("192.168.31.99") client.subscribe("ble/raw") client.loop_start() while True: time.sleep(0.2) dist_vec = [] for b in beacons.values(): if time.time() - b.last_update < 2: # 只收新鲜度 <2 s 的数据 d = rssi_to_dist(b.get_smoothed_rssi()) dist_vec.append((b.x, b.y, d)) if len(dist_vec) >= 3: pos = trilaterate, dist_vec) if pos is not None: print(f"X={pos[0]:.2f} Y={pos[1]:.2f}")要点:
- 卡尔曼只维 1 维 RSSI,计算量 < 200 µs,树莓派 Zero 也能跑。
- 三边定位用线性最小二乘,避免牛顿迭代初值敏感问题。
4.3 自动校准脚本(节选)
def collect_walk(): pts = [] print("请沿折线慢走,每步 1 米,共 10 步,按回车确认") for i in range(10): input(f"站在 ({i},0) 按回车") snapshot = {mac: b.get_smoothed_rssi() for mac, b in beacons.items()} pts.append((i, 0, snapshot)) # 用最小二乘拟合 N、RSSI_1M from scipy.optimize import least_squares def err(p): n, r = p e = [] for x, y, snapshot in pts: for mac, rss in snapshot.items(): bx, by = beacons[mac].x, beacons[mac].y d_real = math.hypot(x-bx, y-by) d_est = 10**((r - rss)/(10*n)) e.append(d_real - d_est) return e res = least_squares(err, [2, -60]) print("拟合完成 N =", res.x[0], "RSSI_1M =", res.x[1])五、性能与安全考量
采样频率 vs 功耗
ESP32 扫描占空比 25% 时整机电流 80 mA;降到 10% 可压到 35 mA,但丢包率由 3% → 8%,需权衡。MAC 地址随机化
iOS 14+ 默认每 15 min 轮换 MAC,解决方案:把信标名字改成beacon_001,用设备名当主键,避开 MAC。数据完整性
MQTT 走明文,毕设演示无所谓;若校赛要求隐私,加 TLS 证书,ESP32 用WiFiClientSecure,内存多占 30 k。滤波延迟
卡尔曼过程噪声 Q 调大,平滑减弱但延迟降低;现场走秀时 Q=2 跟踪更手,静态展示 Q=0.5 轨迹更丝滑。
六、生产环境避坑指南
- 金属遮挡:铁门会反射信号,导致“镜像”信标;部署时让基站离墙 ≥ 30 cm。
- 地面校准:瓷砖与木地板对 2.4 GHz 吸收差 1 dB,别偷懒,换场地就重跑
collect_walk()。 - 并发扫描冲突:同一房间 3 组同学同时演示,把蓝牙信道 37/38/39 占满;错开扫描窗口或把广播间隔提到 200 ms。
- 天线方向:nRF 52832 板载 PCB 天线垂直时 RSSI 最强,挂天花板记得让天线朝下。
- 参考点布设:三边定位尽量让基站夹角 90°–120°,钝角三角形 GDOP 爆炸,误差轻松翻倍。
七、可继续玩的优化方向
- 把卡尔曼升到 3 维 (RSSI + 加速度),利用步子检测抑制“人墙遮挡”瞬跌。
- 用粒子滤波融合地磁指纹,BLE 粗定位 + 磁场精修,1 m 内稳定度可再提 30%。
- 写个安卓端,把 MQTT 可视化搬到 Flutter,现场老师手机装 App 就能看轨迹,答辩加分。
- 调参工具:把滤波 Q/R 做成滑动条,实时看轨迹抖动,半分钟就能找到最优噪声矩阵。
八、小结
整个流程跑通,硬件成本不到一顿火锅钱,代码量 400 行左右,却能把“室内定位”从 PPT 变成可演示的 Demo。别急着堆高大上算法,先让 RSSI 稳定、坐标刷新不飘,再谈精度。下一步,把滤波参数拎出来多轮 A/B 测试,或者把基站扩到 5 个用最小圆覆盖,误差椭圆就能再缩一圈。祝你毕业设计现场不被老师问“这轨迹怎么穿墙了”,而是收获一句——“咦,还挺准!”