扫描蓝牙广播帧

在物联网场景中,BLE(低功耗蓝牙)广播帧是设备主动向外推送状态信息的核心机制。本文介绍如何使用 @abandonware/noble 库扫描并解析蓝牙广播帧,以 BlueTag 温湿度传感器为例,实现广播数据的实时监听与 HTTP API 暴露。

背景

BlueTag TH10/TH30 系列温湿度传感器通过 BLE 广播帧持续推送温度、湿度、电量等数据。广播帧中包含厂商特定数据(Manufacturer Specific Data),CompanyId 为 0x1012,需要按照蓝牙 TH10/TH30 协议 V1.7 进行解析。

技术选型

方案 说明
noble 官方 BLE 库,已停止维护
@abandonware/noble 社区维护的 noble 分支,持续更新,推荐使用

@abandonware/noble 是目前 Node.js BLE 开发的主流选择,支持 Windows / Linux / macOS,需要以管理员权限运行。

核心实现

设备过滤

只关注目标设备,通过 CompanyId 或设备名称前缀过滤:

1
2
3
4
5
6
7
8
9
10
11
const TARGET_COMPANY_ID = 0x1012;
const TARGET_NAME = 'XJ_T01';

function isTargetDevice(ad) {
if (ad.localName && ad.localName.startsWith(TARGET_NAME)) return true;
if (ad.manufacturerData && ad.manufacturerData.length >= 2) {
const companyId = ad.manufacturerData.readUInt16LE(0);
if (companyId === TARGET_COMPANY_ID) return true;
}
return false;
}

manufacturerData 数据结构

BLE 广播帧中的厂商特定数据(Manufacturer Specific Data)是解析的核心。noble 回调中的 peripheral.advertisement.manufacturerData 就是一个 Buffer,包含完整的厂商数据。

manufacturerData 整体布局

1
2
3
4
┌─── manufacturerData Buffer ───────────────────────────────────────┐
│ CompanyId(2B) │ 厂商自定义数据(长度可变) │
0x1012 │ < 27B → 广播数据(ADV) │ >= 27B → 扫描响应(ScanResp)
└──────────────────────────────────────────────────────────────────┘

前 2 字节是 Company ID(小端序),0x1012 代表 BlueTag 厂商。后续数据的解析方式取决于总长度:

  • 长度 < 27:广播数据(ADV),包含温湿度等实时测量值
  • 长度 >= 27:扫描响应(Scan Response),包含 MAC、序列号、绑定状态等设备信息

广播数据(ADV)逐字节解析

以 CompanyId=0x1012 的广播数据为例,偏移从 CompanyId 之后(off=2)开始:

1
2
3
4
5
6
7
8
9
10
偏移:  0  1  2  3  4  5  6  7  8  9  10 11 12 13 14
│ │ │ ├──currentTemp──┤ ├──humidity──┤ ├──probeTemp──┤
│ │ │ │ │ │ │ │ │
CI CI DI UD BT T1 T2 H1 H2 P1 P2 AT1 AT2 AH DT
↑ ↑ ↑ ↑ ├──────┤ ├────────────┤ ├────────────┤
│ │ │ │ 大端序 大端序 大端序
│ │ │ 温度原始值 湿度原始值 探头温度原始值
│ │ 电量
│ 使用天数
设备信息1
偏移 (off+) 长度 字段 读取方式 说明
0 1 deviceInfo1 buf[off + 0] 设备信息字节,含标志位
1 1 usageDays buf[off + 1] 设备已使用天数
2 1 battery buf[off + 2] 电量百分比(0-100)
3 2 currentTemp readUInt16BE(off + 3) 内置温度原始值(大端序)
5 2 humidity readUInt16BE(off + 5) 湿度原始值(大端序)
7 2 probeTemp readUInt16BE(off + 7) 探头温度原始值(大端序),0xFFFF=未接
9 2 alarmTemp readUInt16BE(off + 9) 报警温度(可选,payload>=11 时存在)
11 1 alarm1Humidity buf[off + 11] 报警湿度(可选,payload>=12 时存在)
12 1 deviceTypeCode buf[off + 12] 设备类型码(可选,payload>=13 时存在)

温度换算公式取决于 deviceTypeCode:

1
2
3
4
5
// 普通温度设备(T10, TH10B, TH10R, TH20, TH30B, TH30R)
const parseTemp = (raw) => (raw - 300) / 10; // raw=530 → 23.0°C

// 大温度设备(TH30R-ETU, TH30R-I, TH10R-I)
const parseTemp = (raw) => (raw - 2000) / 10; // raw=4530 → 253.0°C

湿度换算:humidity / 10,例如 raw=452 → 45.2%RH。

扫描响应(Scan Response)逐字节解析

当 manufacturerData 长度 >= 27 字节时,按扫描响应格式解析:

1
2
3
4
5
偏移:  0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
CI CI ├──MAC(6B)──┤ ├──SN(4B)──┤ DT DT DT AF BT BS PV A1 A2 ├──AT2──┤ ├──AH2──┤ UD TX
↑ ↑ ↑ ↑ ↑ ↑ ↑
│ 小端序MAC 序列号 日期字节 协议版本 报警2温度 发射功率
CompanyId
偏移 (off+) 长度 字段 读取方式 说明
0-5 6 MAC 地址 逆序读取 buf[off+5]buf[off] 小端序存储,需逆序拼成 AA:BB:CC:DD:EE:FF
6-9 4 序列号 toString('hex', off+6, off+10) 十六进制序列号
10-12 3 日期 3 字节 hex 生产/校准日期
13 1 alarmFlag buf[off + 13] 报警标志位
14 1 battery buf[off + 14] 电量百分比
15 1 bindStatus buf[off + 15] 绑定状态:0=未绑定, 非0=已绑定
16 1 protocolVersion buf[off + 16] 高4位=主版本, 低4位=次版本,如 0x17 → V1.7
17 1 alarm1 buf[off + 17] 报警1 配置
18 1 alarm2 buf[off + 18] 报警2 配置
19-20 2 alarm2Temp readUInt16BE(off + 19) 报警2温度原始值,0xFFFF=未设置
21-22 2 alarm2Humidity readUInt16BE(off + 21) 报警2湿度原始值,0xFFFF=未设置
23 1 usageDays buf[off + 23] 已使用天数
24 1 txPower readInt8(off + 24) 发射功率(有符号,如 +4 dBm)

MAC 地址读取的特殊处理——6 字节小端序存储,需要从高地址向低地址读取:

1
2
3
4
const macBytes = [];
for (let i = off + 5; i >= off; i--)
macBytes.push(buf[i].toString(16).padStart(2, '0'));
const mac = macBytes.join(':'); // 例如 "AA:BB:CC:DD:EE:FF"

协议版本解析——单字节中高 4 位为主版本,低 4 位为次版本:

1
2
3
const verMajor = (protocolVersion >> 4) & 0xF;  // 0x17 → 1
const verMinor = protocolVersion & 0xF; // 0x17 → 7
const versionStr = `V${verMajor}.${verMinor}`; // "V1.7"

完整解析示例

假设收到一条 manufacturerData 的十六进制数据:

1
2
3
12 10 00 1E 5B 02 12 01 CC 00 00 00 00 72
│ │ │ │ │ ├─┤ ├─┤ ├─┤
CI CI DI UD BT TP TP HM HM

解析过程:

  1. CompanyId = 0x1012(小端序 12 10)→ 目标设备
  2. deviceInfo1 = 0x00
  3. usageDays = 0x1E = 30 天
  4. battery = 0x5B = 91%
  5. currentTemp = 0x0212(大端序)= 530 → (530-300)/10 = 23.0°C
  6. humidity = 0x01CC(大端序)= 460 → 460/10 = 46.0%RH

广播数据代码实现

根据上面的字节布局,解析函数的核心逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function parseTargetManufacturerData(mfrData) {
const buf = Buffer.isBuffer(mfrData) ? mfrData : Buffer.from(mfrData);
const companyId = buf.readUInt16LE(0); // 前 2 字节:CompanyId(小端序)
if (companyId !== TARGET_COMPANY_ID) return null;

if (buf.length >= 27) return parseScanResponse(buf); // 长度>=27 走扫描响应

// 广播数据:从偏移 2 开始逐字段读取
const off = 2;
const deviceInfo1 = buf[off + 0];
const usageDays = buf[off + 1];
const battery = buf[off + 2];
const currentTemp = buf.readUInt16BE(off + 3); // 大端序
const humidity = buf.readUInt16BE(off + 5);
const probeTemp = buf.readUInt16BE(off + 7);

// 可选字段:根据 payload 长度判断是否存在
let alarmTemp = null, deviceTypeCode = null;
const payloadLen = buf.length - off;
if (payloadLen >= 11) alarmTemp = buf.readUInt16BE(off + 9);
if (payloadLen >= 13) deviceTypeCode = buf[off + 12];

// 根据 deviceTypeCode 选择温度换算公式
const isBigTemp = deviceTypeCode !== null && BIG_TEMP_DEVICES.has(deviceTypeCode);
const parseTemp = isBigTemp ? (r) => (r - 2000) / 10 : (r) => (r - 300) / 10;

return {
currentTempCelsius: parseTemp(currentTemp).toFixed(1) + '°C',
probeTempCelsius: probeTemp === 0xFFFF ? null : parseTemp(probeTemp).toFixed(1) + '°C',
humidityPercent: (humidity / 10).toFixed(1) + '%RH',
battery: `${battery}%`,
deviceType: DEVICE_TYPES[deviceTypeCode] || 'N/A',
};
}

设备类型映射

1
2
3
4
5
const DEVICE_TYPES = {
0x70: 'BlueTag T10', 0x71: 'BlueTag TH10B', 0x72: 'BlueTag TH10R',
0x73: 'BlueTag TH20', 0x74: 'BlueTag TH30B', 0x75: 'BlueTag TH30R',
0x77: 'BlueTag TH30R-ETU', 0x78: 'BlueTag TH30R-I', 0x79: 'BlueTag TH10R-I',
};

BLE 扫描流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
noble.on('stateChange', function (state) {
if (state === 'poweredOn') {
noble.startScanning([], true); // 空数组=接受所有服务, true=允许重复广播
}
});

noble.on('discover', function (peripheral) {
if (!isTargetDevice(peripheral.advertisement)) return;

const parsed = parseTargetManufacturerData(
peripheral.advertisement.manufacturerData
);
// 广播数据缓存,扫描响应仅控制台输出
});

注意 startScanning 的第二个参数设为 true,允许接收重复广播,这对温湿度传感器等周期性广播设备是必要的。

HTTP API

通过 Express 暴露 REST 接口,供其他系统查询设备数据:

方法 路径 说明
GET /blue/status 蓝牙适配器状态及扫描状态
POST /blue/scan/start 开始 BLE 广播扫描
POST /blue/scan/stop 停止扫描
GET /blue/0x1012/data 获取缓存数据(支持 ?name= 过滤)

设备数据按 MAC 地址去重缓存,广播数据持续更新,扫描响应仅控制台输出不缓存。

运行方式

1
2
# 以管理员身份运行终端
node scan-abandonware-noble.js

输出示例:

1
2
[10:30:15] AA:BB:CC:DD:EE:FF XJ_T01 | BlueTag TH10R 内置:25.3°C 探头:未接 湿度:45.2%RH 电量:87% RSSI:-42 raw:1210010...
[10:30:15] AA:BB:CC:DD:EE:FF XJ_T01 | [ScanResp] MAC:AA:BB:CC:DD:EE:FF SN:abc123 协议:V1.7 电量:87% 绑定:已绑定 功率:4 dBm RSSI:-42 raw:1210...

注意事项

  1. 管理员权限:Windows 下需要管理员权限才能访问蓝牙适配器
  2. 原生模块编译@abandonware/noble 需要编译原生模块,需安装 VS Build Tools
  3. 蓝牙适配器独占:扫描期间蓝牙适配器被独占,其他程序可能无法同时使用
  4. 广播间隔:传感器通常每秒广播一次,startScanning([], true) 确保能收到每次广播

总结

通过 @abandonware/noble 可以方便地实现 BLE 广播帧的扫描与解析。关键在于理解厂商特定数据的二进制格式,正确处理大小端序和温度换算公式。配合 HTTP API,可以将蓝牙传感器数据快速集成到更大的物联网系统中。


扫描蓝牙广播帧
https://cszy.top/20260420-扫描蓝牙广播帧/
作者
csorz
发布于
2026年4月20日
许可协议