Coverage for src/bluetooth_sig/types/advertising.py: 95%

279 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 00:10 +0000

1"""BLE Advertising data types and parsing utilities.""" 

2 

3from __future__ import annotations 

4 

5from enum import IntEnum, IntFlag 

6 

7import msgspec 

8 

9 

10class PDUFlags(IntFlag): 

11 """BLE PDU parsing bit masks for header operations. 

12 

13 These masks are pre-positioned to their correct bit locations, 

14 eliminating the need for shifts during extraction. 

15 """ 

16 

17 TYPE_MASK = 0x0F 

18 RFU_BIT_4 = 0x10 

19 RFU_BIT_5 = 0x20 

20 TX_ADD_MASK = 0x40 

21 RX_ADD_MASK = 0x80 

22 

23 @classmethod 

24 def extract_bits(cls, header: int, mask: int) -> int | bool: 

25 """Extract bits from header using the specified mask. 

26 

27 Returns int for multi-bit masks, bool for single-bit masks. 

28 """ 

29 value = header & mask 

30 # If mask has multiple bits set, return the raw value 

31 # If mask has only one bit set, return boolean 

32 if mask & (mask - 1): # Check if mask has multiple bits set 

33 return value 

34 return bool(value) 

35 

36 @classmethod 

37 def extract_pdu_type(cls, header: int) -> PDUType: 

38 """Extract PDU type from header byte and return as PDUType enum.""" 

39 raw_type = int(cls.extract_bits(header, cls.TYPE_MASK)) 

40 try: 

41 return PDUType(raw_type) 

42 except ValueError as exc: 

43 # For unknown PDU types, we could either raise or return a special value 

44 raise ValueError(f"Unknown PDU type: 0x{raw_type:02X}") from exc 

45 

46 @classmethod 

47 def extract_tx_add(cls, header: int) -> bool: 

48 """Extract TX address type from header.""" 

49 return bool(cls.extract_bits(header, cls.TX_ADD_MASK)) 

50 

51 @classmethod 

52 def extract_rx_add(cls, header: int) -> bool: 

53 """Extract RX address type from header.""" 

54 return bool(cls.extract_bits(header, cls.RX_ADD_MASK)) 

55 

56 

57class PDUConstants: 

58 """BLE PDU parsing constants for sizes and offsets. 

59 

60 Following best practices, this uses a class for related 

61 constants rather than mixing them with enums/flags. 

62 """ 

63 

64 # PDU Size constants 

65 BLE_ADDR: int = 6 

66 AUX_PTR: int = 3 

67 ADV_DATA_INFO: int = 2 

68 CTE_INFO: int = 1 

69 SYNC_INFO: int = 18 

70 TX_POWER: int = 1 

71 PDU_HEADER: int = 2 

72 MIN_EXTENDED_PDU: int = 3 

73 EXT_HEADER_LENGTH: int = 1 

74 

75 # PDU Offsets 

76 EXTENDED_HEADER_START: int = 3 

77 ADV_MODE: int = 1 

78 ADV_ADDR_OFFSET: int = 2 

79 TARGET_ADDR_OFFSET: int = 2 

80 CTE_INFO_OFFSET: int = 1 

81 ADV_DATA_INFO_OFFSET: int = 2 

82 AUX_PTR_OFFSET: int = 3 

83 SYNC_INFO_OFFSET: int = 18 

84 TX_POWER_OFFSET: int = 1 

85 PDU_LENGTH_OFFSET: int = 2 

86 

87 

88class ExtendedHeaderMode(IntEnum): 

89 """Extended Header Mode bit masks (BLE 5.0+).""" 

90 

91 ADV_ADDR = 0x01 

92 TARGET_ADDR = 0x02 

93 CTE_INFO = 0x04 

94 ADV_DATA_INFO = 0x08 

95 AUX_PTR = 0x10 

96 SYNC_INFO = 0x20 

97 TX_POWER = 0x40 

98 ACAD = 0x80 

99 

100 

101class PDUType(IntEnum): 

102 """BLE Advertising PDU Types.""" 

103 

104 ADV_IND = 0x00 

105 ADV_DIRECT_IND = 0x01 

106 ADV_NONCONN_IND = 0x02 

107 SCAN_REQ = 0x03 

108 SCAN_RSP = 0x04 

109 CONNECT_IND = 0x05 

110 ADV_SCAN_IND = 0x06 

111 ADV_EXT_IND = 0x07 

112 ADV_AUX_IND = 0x08 

113 

114 @property 

115 def is_extended_advertising(self) -> bool: 

116 """Check if this is an extended advertising PDU.""" 

117 return self in (PDUType.ADV_EXT_IND, PDUType.ADV_AUX_IND) 

118 

119 @property 

120 def is_legacy_advertising(self) -> bool: 

121 """Check if this is a legacy advertising PDU.""" 

122 return self in ( 

123 PDUType.ADV_IND, 

124 PDUType.ADV_DIRECT_IND, 

125 PDUType.ADV_NONCONN_IND, 

126 PDUType.SCAN_REQ, 

127 PDUType.SCAN_RSP, 

128 PDUType.CONNECT_IND, 

129 PDUType.ADV_SCAN_IND, 

130 ) 

131 

132 

133class BLEAdvertisementTypes(IntEnum): 

134 """BLE Advertisement Data Types (AD Types) as defined in Bluetooth Core Specification.""" 

135 

136 # Legacy Advertising AD Types 

137 FLAGS = 0x01 

138 INCOMPLETE_16BIT_SERVICE_UUIDS = 0x02 

139 COMPLETE_16BIT_SERVICE_UUIDS = 0x03 

140 INCOMPLETE_32BIT_SERVICE_UUIDS = 0x04 

141 COMPLETE_32BIT_SERVICE_UUIDS = 0x05 

142 INCOMPLETE_128BIT_SERVICE_UUIDS = 0x06 

143 COMPLETE_128BIT_SERVICE_UUIDS = 0x07 

144 SHORTENED_LOCAL_NAME = 0x08 

145 COMPLETE_LOCAL_NAME = 0x09 

146 TX_POWER_LEVEL = 0x0A 

147 CLASS_OF_DEVICE = 0x0D 

148 SIMPLE_PAIRING_HASH_C = 0x0E 

149 SIMPLE_PAIRING_RANDOMIZER_R = 0x0F 

150 SECURITY_MANAGER_TK_VALUE = 0x10 

151 SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11 

152 SLAVE_CONNECTION_INTERVAL_RANGE = 0x12 

153 SOLICITED_SERVICE_UUIDS_16BIT = 0x14 

154 SOLICITED_SERVICE_UUIDS_128BIT = 0x15 

155 SERVICE_DATA_16BIT = 0x16 

156 PUBLIC_TARGET_ADDRESS = 0x17 

157 RANDOM_TARGET_ADDRESS = 0x18 

158 APPEARANCE = 0x19 

159 ADVERTISING_INTERVAL = 0x1A 

160 LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B 

161 LE_ROLE = 0x1C 

162 SIMPLE_PAIRING_HASH_C256 = 0x1D 

163 SIMPLE_PAIRING_RANDOMIZER_R256 = 0x1E 

164 SERVICE_DATA_32BIT = 0x20 

165 SERVICE_DATA_128BIT = 0x21 

166 SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22 

167 SECURE_CONNECTIONS_RANDOM_VALUE = 0x23 

168 URI = 0x24 

169 INDOOR_POSITIONING = 0x25 

170 TRANSPORT_DISCOVERY_DATA = 0x26 

171 LE_SUPPORTED_FEATURES = 0x27 

172 CHANNEL_MAP_UPDATE_INDICATION = 0x28 

173 PB_ADV = 0x29 

174 MESH_MESSAGE = 0x2A 

175 MESH_BEACON = 0x2B 

176 BIGINFO = 0x2C 

177 BROADCAST_CODE = 0x2D 

178 RESOLVABLE_SET_IDENTIFIER = 0x2E 

179 ADVERTISING_INTERVAL_LONG = 0x2F 

180 BROADCAST_NAME = 0x30 

181 ENCRYPTED_ADVERTISING_DATA = 0x31 

182 PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION = 0x32 

183 ELECTRONIC_SHELF_LABEL = 0x34 

184 THREE_D_INFORMATION_DATA = 0x3D 

185 MANUFACTURER_SPECIFIC_DATA = 0xFF 

186 

187 

188class BLEAdvertisingFlags(IntFlag): 

189 """BLE Advertising Flags as defined in Bluetooth Core Specification Supplement. 

190 

191 These flags indicate the discoverable mode and capabilities of the advertising device. 

192 """ 

193 

194 LE_LIMITED_DISCOVERABLE_MODE = 0x01 

195 LE_GENERAL_DISCOVERABLE_MODE = 0x02 

196 BR_EDR_NOT_SUPPORTED = 0x04 

197 SIMULTANEOUS_LE_BR_EDR_CONTROLLER = 0x08 

198 SIMULTANEOUS_LE_BR_EDR_HOST = 0x10 

199 RESERVED_BIT_5 = 0x20 

200 RESERVED_BIT_6 = 0x40 

201 RESERVED_BIT_7 = 0x80 

202 

203 

204class BLEExtendedHeader(msgspec.Struct, kw_only=True): 

205 """Extended Advertising Header fields (BLE 5.0+).""" 

206 

207 extended_header_length: int = 0 

208 adv_mode: int = 0 

209 

210 extended_advertiser_address: bytes = b"" 

211 extended_target_address: bytes = b"" 

212 cte_info: bytes = b"" 

213 advertising_data_info: bytes = b"" 

214 auxiliary_pointer: bytes = b"" 

215 sync_info: bytes = b"" 

216 tx_power: int | None = None 

217 additional_controller_advertising_data: bytes = b"" 

218 

219 @property 

220 def has_extended_advertiser_address(self) -> bool: 

221 """Check if extended advertiser address is present.""" 

222 return bool(self.adv_mode & ExtendedHeaderMode.ADV_ADDR) 

223 

224 @property 

225 def has_extended_target_address(self) -> bool: 

226 """Check if extended target address is present.""" 

227 return bool(self.adv_mode & ExtendedHeaderMode.TARGET_ADDR) 

228 

229 @property 

230 def has_cte_info(self) -> bool: 

231 """Check if CTE info is present.""" 

232 return bool(self.adv_mode & ExtendedHeaderMode.CTE_INFO) 

233 

234 @property 

235 def has_advertising_data_info(self) -> bool: 

236 """Check if advertising data info is present.""" 

237 return bool(self.adv_mode & ExtendedHeaderMode.ADV_DATA_INFO) 

238 

239 @property 

240 def has_auxiliary_pointer(self) -> bool: 

241 """Check if auxiliary pointer is present.""" 

242 return bool(self.adv_mode & ExtendedHeaderMode.AUX_PTR) 

243 

244 @property 

245 def has_sync_info(self) -> bool: 

246 """Check if sync info is present.""" 

247 return bool(self.adv_mode & ExtendedHeaderMode.SYNC_INFO) 

248 

249 @property 

250 def has_tx_power(self) -> bool: 

251 """Check if TX power is present.""" 

252 return bool(self.adv_mode & ExtendedHeaderMode.TX_POWER) 

253 

254 @property 

255 def has_additional_controller_data(self) -> bool: 

256 """Check if additional controller advertising data is present.""" 

257 return bool(self.adv_mode & ExtendedHeaderMode.ACAD) 

258 

259 

260class BLEAdvertisingPDU(msgspec.Struct, kw_only=True): 

261 """BLE Advertising PDU structure.""" 

262 

263 pdu_type: PDUType 

264 tx_add: bool 

265 rx_add: bool 

266 length: int 

267 advertiser_address: bytes = b"" 

268 target_address: bytes = b"" 

269 payload: bytes = b"" 

270 extended_header: BLEExtendedHeader | None = None 

271 

272 @property 

273 def is_extended_advertising(self) -> bool: 

274 """Check if this is an extended advertising PDU.""" 

275 return self.pdu_type.is_extended_advertising 

276 

277 @property 

278 def is_legacy_advertising(self) -> bool: 

279 """Check if this is a legacy advertising PDU.""" 

280 return self.pdu_type.is_legacy_advertising 

281 

282 @property 

283 def pdu_name(self) -> str: 

284 """Get human-readable PDU type name.""" 

285 return self.pdu_type.name 

286 

287 

288class ParsedADStructures(msgspec.Struct, kw_only=True): 

289 """Parsed Advertising Data structures from advertisement payload.""" 

290 

291 manufacturer_data: dict[int, bytes] = msgspec.field(default_factory=dict) 

292 service_uuids: list[str] = msgspec.field(default_factory=list) 

293 local_name: str = "" 

294 tx_power: int = 0 

295 flags: BLEAdvertisingFlags = BLEAdvertisingFlags(0) 

296 appearance: int | None = None 

297 service_data: dict[str, bytes] = msgspec.field(default_factory=dict) 

298 solicited_service_uuids: list[str] = msgspec.field(default_factory=list) 

299 uri: str = "" 

300 indoor_positioning: bytes = b"" 

301 transport_discovery_data: bytes = b"" 

302 le_supported_features: bytes = b"" 

303 encrypted_advertising_data: bytes = b"" 

304 periodic_advertising_response_timing: bytes = b"" 

305 electronic_shelf_label: bytes = b"" 

306 three_d_information: bytes = b"" 

307 broadcast_name: str = "" 

308 broadcast_code: bytes = b"" 

309 biginfo: bytes = b"" 

310 mesh_message: bytes = b"" 

311 mesh_beacon: bytes = b"" 

312 public_target_address: list[str] = msgspec.field(default_factory=list) 

313 random_target_address: list[str] = msgspec.field(default_factory=list) 

314 advertising_interval: int | None = None 

315 advertising_interval_long: int | None = None 

316 le_bluetooth_device_address: str = "" 

317 le_role: int | None = None 

318 class_of_device: int | None = None 

319 simple_pairing_hash_c: bytes = b"" 

320 simple_pairing_randomizer_r: bytes = b"" 

321 security_manager_tk_value: bytes = b"" 

322 security_manager_out_of_band_flags: bytes = b"" 

323 slave_connection_interval_range: bytes = b"" 

324 secure_connections_confirmation: bytes = b"" 

325 secure_connections_random: bytes = b"" 

326 channel_map_update_indication: bytes = b"" 

327 pb_adv: bytes = b"" 

328 resolvable_set_identifier: bytes = b"" 

329 

330 

331class DeviceAdvertiserData(msgspec.Struct, kw_only=True): 

332 """Parsed advertiser data from device discovery.""" 

333 

334 raw_data: bytes 

335 local_name: str = "" 

336 manufacturer_data: dict[int, bytes] = msgspec.field(default_factory=dict) 

337 service_uuids: list[str] = msgspec.field(default_factory=list) 

338 tx_power: int | None = None 

339 rssi: int | None = None 

340 flags: BLEAdvertisingFlags | None = None 

341 

342 # Additional parsed fields 

343 appearance: int | None = None 

344 service_data: dict[str, bytes] = msgspec.field(default_factory=dict) 

345 solicited_service_uuids: list[str] = msgspec.field(default_factory=list) 

346 uri: str = "" 

347 indoor_positioning: bytes = b"" 

348 transport_discovery_data: bytes = b"" 

349 le_supported_features: bytes = b"" 

350 encrypted_advertising_data: bytes = b"" 

351 periodic_advertising_response_timing: bytes = b"" 

352 electronic_shelf_label: bytes = b"" 

353 three_d_information: bytes = b"" 

354 broadcast_name: str = "" 

355 biginfo: bytes = b"" 

356 mesh_message: bytes = b"" 

357 mesh_beacon: bytes = b"" 

358 public_target_address: list[str] = msgspec.field(default_factory=list) 

359 random_target_address: list[str] = msgspec.field(default_factory=list) 

360 advertising_interval: int | None = None 

361 advertising_interval_long: int | None = None 

362 le_bluetooth_device_address: str = "" 

363 le_role: int | None = None 

364 class_of_device: int | None = None 

365 simple_pairing_hash_c: bytes = b"" 

366 simple_pairing_randomizer_r: bytes = b"" 

367 security_manager_tk_value: bytes = b"" 

368 security_manager_out_of_band_flags: bytes = b"" 

369 slave_connection_interval_range: bytes = b"" 

370 secure_connections_confirmation: bytes = b"" 

371 secure_connections_random: bytes = b"" 

372 channel_map_update_indication: bytes = b"" 

373 pb_adv: bytes = b"" 

374 resolvable_set_identifier: bytes = b"" 

375 

376 extended_payload: bytes = b"" 

377 auxiliary_packets: list[BLEAdvertisingPDU] = msgspec.field(default_factory=list) 

378 periodic_advertising_data: bytes = b"" 

379 broadcast_code: bytes = b"" 

380 

381 @property 

382 def is_extended_advertising(self) -> bool: 

383 """Check if this advertisement uses extended advertising.""" 

384 return bool(self.extended_payload) or bool(self.auxiliary_packets) 

385 

386 @property 

387 def total_payload_size(self) -> int: 

388 """Get total payload size including extended data.""" 

389 base_size = len(self.raw_data) 

390 if self.extended_payload: 

391 base_size += len(self.extended_payload) 

392 for aux_packet in self.auxiliary_packets: 

393 base_size += len(aux_packet.payload) 

394 return base_size