在物联网场景中,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(2 B) │ 厂商自定义数据(长度可变) │ │ 0x1012 │ < 27 B → 广播数据(ADV) │ >= 27 B → 扫描响应(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 const parseTemp = (raw ) => (raw - 300 ) / 10 ; const parseTemp = (raw ) => (raw - 2000 ) / 10 ;
湿度换算: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 (':' );
协议版本解析——单字节中高 4 位为主版本,低 4 位为次版本:
1 2 3 const verMajor = (protocolVersion >> 4 ) & 0xF ; const verMinor = protocolVersion & 0xF ; const versionStr = `V${verMajor} .${verMinor} ` ;
完整解析示例 假设收到一条 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
解析过程:
CompanyId = 0x1012(小端序 12 10)→ 目标设备
deviceInfo1 = 0x00
usageDays = 0x1E = 30 天
battery = 0x5B = 91%
currentTemp = 0x0212(大端序)= 530 → (530-300)/10 = 23.0°C
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 ); if (companyId !== TARGET_COMPANY_ID ) return null ; if (buf.length >= 27 ) return parseScanResponse (buf); 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 ); 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 ]; 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 ); } }); 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 ...
注意事项
管理员权限 :Windows 下需要管理员权限才能访问蓝牙适配器
原生模块编译 :@abandonware/noble 需要编译原生模块,需安装 VS Build Tools
蓝牙适配器独占 :扫描期间蓝牙适配器被独占,其他程序可能无法同时使用
广播间隔 :传感器通常每秒广播一次,startScanning([], true) 确保能收到每次广播
总结 通过 @abandonware/noble 可以方便地实现 BLE 广播帧的扫描与解析。关键在于理解厂商特定数据的二进制格式,正确处理大小端序和温度换算公式。配合 HTTP API,可以将蓝牙传感器数据快速集成到更大的物联网系统中。