Coverage for src / bluetooth_sig / types / advertising / builder.py: 87%

110 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +0000

1"""Advertisement data builder for BLE peripheral encoding. 

2 

3This module provides generic encoding functionality for BLE advertising data, 

4converting structured data into properly formatted AD structures that can be 

5broadcast by peripheral devices. 

6 

7Reference: Bluetooth Core Specification Supplement, Part A (Advertising Data). 

8""" 

9 

10from __future__ import annotations 

11 

12import struct 

13from collections.abc import Sequence 

14 

15import msgspec 

16 

17from bluetooth_sig.types.ad_types_constants import ADType 

18from bluetooth_sig.types.advertising.flags import BLEAdvertisingFlags 

19from bluetooth_sig.types.company import CompanyIdentifier, ManufacturerData 

20from bluetooth_sig.types.uuid import BluetoothUUID 

21 

22# Maximum data length for a single AD structure (255 - 1 for length byte - 1 for type byte) 

23AD_STRUCTURE_MAX_DATA_SIZE: int = 254 

24 

25 

26class ADStructure(msgspec.Struct, frozen=True, kw_only=True): 

27 """Single AD structure (Length-Type-Data format). 

28 

29 Each AD structure consists of: 

30 - 1 byte: Length (length of type + data) 

31 - 1 byte: AD Type 

32 - N bytes: Data 

33 

34 Attributes: 

35 ad_type: The AD type constant (e.g., ADType.FLAGS, ADType.COMPLETE_LOCAL_NAME). 

36 data: The raw data bytes for this AD structure. 

37 

38 """ 

39 

40 ad_type: int 

41 data: bytes 

42 

43 def to_bytes(self) -> bytes: 

44 """Encode to wire format (length-type-data). 

45 

46 Returns: 

47 Encoded bytes ready for advertising payload. 

48 

49 Raises: 

50 ValueError: If data exceeds maximum AD structure size (254 bytes). 

51 

52 """ 

53 if len(self.data) > AD_STRUCTURE_MAX_DATA_SIZE: 

54 raise ValueError(f"AD structure data too long: {len(self.data)} bytes, max {AD_STRUCTURE_MAX_DATA_SIZE}") 

55 length = len(self.data) + 1 # +1 for AD type byte 

56 return bytes([length, self.ad_type]) + self.data 

57 

58 def __len__(self) -> int: 

59 """Total encoded length including length byte.""" 

60 return 2 + len(self.data) 

61 

62 

63class AdvertisementBuilder(msgspec.Struct, frozen=True, kw_only=True): 

64 r"""Builder for constructing BLE advertising payloads. 

65 

66 Provides a fluent interface for building advertising data with proper 

67 encoding according to Bluetooth Core Specification. 

68 

69 The builder accumulates AD structures and can produce a final encoded 

70 payload suitable for use with BLE peripheral APIs. 

71 

72 Example:: 

73 >>> from bluetooth_sig.types.advertising.builder import AdvertisementBuilder 

74 >>> from bluetooth_sig.types.advertising.flags import BLEAdvertisingFlags 

75 >>> 

76 >>> # Build advertising payload 

77 >>> builder = AdvertisementBuilder() 

78 >>> builder = builder.with_flags( 

79 ... BLEAdvertisingFlags.LE_GENERAL_DISCOVERABLE_MODE | BLEAdvertisingFlags.BR_EDR_NOT_SUPPORTED 

80 ... ) 

81 >>> builder = builder.with_complete_local_name("MySensor") 

82 >>> builder = builder.with_service_uuids(["180F", "181A"]) 

83 >>> builder = builder.with_manufacturer_data(0x004C, b"\x02\x15...") 

84 >>> 

85 >>> payload = builder.build() 

86 

87 Attributes: 

88 structures: List of AD structures accumulated so far. 

89 max_payload_size: Maximum payload size (31 for legacy, 254 for extended). 

90 

91 """ 

92 

93 # Standard advertising payload limits 

94 LEGACY_MAX_SIZE: int = 31 

95 EXTENDED_MAX_SIZE: int = 254 

96 

97 structures: list[ADStructure] = msgspec.field(default_factory=list) 

98 max_payload_size: int = LEGACY_MAX_SIZE 

99 

100 def _add_structure(self, ad_type: int, data: bytes) -> AdvertisementBuilder: 

101 """Add an AD structure (returns new builder, immutable pattern).""" 

102 new_structure = ADStructure(ad_type=ad_type, data=data) 

103 return AdvertisementBuilder( 

104 structures=[*self.structures, new_structure], 

105 max_payload_size=self.max_payload_size, 

106 ) 

107 

108 def with_extended_advertising(self) -> AdvertisementBuilder: 

109 """Enable extended advertising mode (up to 254 bytes). 

110 

111 Returns: 

112 New builder with extended payload size limit. 

113 

114 """ 

115 return AdvertisementBuilder( 

116 structures=list(self.structures), 

117 max_payload_size=self.EXTENDED_MAX_SIZE, 

118 ) 

119 

120 def with_flags(self, flags: BLEAdvertisingFlags | int) -> AdvertisementBuilder: 

121 """Add advertising flags. 

122 

123 Args: 

124 flags: BLEAdvertisingFlags enum or raw int value. 

125 

126 Returns: 

127 New builder with flags added. 

128 

129 """ 

130 flag_value = int(flags) 

131 return self._add_structure(ADType.FLAGS, bytes([flag_value])) 

132 

133 def with_complete_local_name(self, name: str) -> AdvertisementBuilder: 

134 """Add complete local name. 

135 

136 Args: 

137 name: Device name (UTF-8 encoded). 

138 

139 Returns: 

140 New builder with name added. 

141 

142 """ 

143 return self._add_structure( 

144 ADType.COMPLETE_LOCAL_NAME, 

145 name.encode("utf-8"), 

146 ) 

147 

148 def with_shortened_local_name(self, name: str) -> AdvertisementBuilder: 

149 """Add shortened local name. 

150 

151 Args: 

152 name: Shortened device name (UTF-8 encoded). 

153 

154 Returns: 

155 New builder with shortened name added. 

156 

157 """ 

158 return self._add_structure( 

159 ADType.SHORTENED_LOCAL_NAME, 

160 name.encode("utf-8"), 

161 ) 

162 

163 def with_tx_power(self, power_dbm: int) -> AdvertisementBuilder: 

164 """Add TX power level. 

165 

166 Args: 

167 power_dbm: Transmission power in dBm (-127 to +127). 

168 

169 Returns: 

170 New builder with TX power added. 

171 

172 """ 

173 # TX power is signed int8 

174 data = struct.pack("b", power_dbm) 

175 return self._add_structure(ADType.TX_POWER_LEVEL, data) 

176 

177 def with_service_uuids( 

178 self, 

179 uuids: Sequence[str | BluetoothUUID], 

180 *, 

181 complete: bool = True, 

182 ) -> AdvertisementBuilder: 

183 """Add service UUIDs to advertising data. 

184 

185 Automatically selects the appropriate AD type based on UUID format: 

186 - 16-bit UUIDs (e.g., "180F") -> Compact 2-byte encoding 

187 - 128-bit UUIDs -> Full 16-byte encoding 

188 

189 Args: 

190 uuids: Service UUIDs to advertise. 

191 complete: If True, use "Complete" list types; else "Incomplete". 

192 

193 Returns: 

194 New builder with service UUIDs added (unchanged if uuids is empty). 

195 

196 """ 

197 if not uuids: 

198 # Return new instance to maintain immutable pattern 

199 return AdvertisementBuilder( 

200 structures=list(self.structures), 

201 max_payload_size=self.max_payload_size, 

202 ) 

203 

204 builder = self 

205 

206 # Group by size for efficient encoding 

207 uuid_16bit: list[bytes] = [] 

208 uuid_128bit: list[bytes] = [] 

209 

210 for uuid in uuids: 

211 bt_uuid = BluetoothUUID(str(uuid)) 

212 full_form = bt_uuid.full_form 

213 

214 # Check if this uses the SIG base UUID (can be encoded as 16-bit) 

215 if full_form.endswith(BluetoothUUID.SIG_BASE_SUFFIX) and full_form.startswith("0000"): 

216 # Use compact 16-bit encoding for SIG UUIDs 

217 short_hex = full_form[4:8] 

218 short_id = int(short_hex, 16) 

219 uuid_16bit.append(struct.pack("<H", short_id)) 

220 else: 

221 # Custom 128-bit UUID 

222 uuid_128bit.append(bt_uuid.to_bytes()) 

223 

224 # Add 16-bit service UUIDs 

225 if uuid_16bit: 

226 ad_type = ADType.COMPLETE_16BIT_SERVICE_UUIDS if complete else ADType.INCOMPLETE_16BIT_SERVICE_UUIDS 

227 builder = builder._add_structure(ad_type, b"".join(uuid_16bit)) 

228 

229 # Add 128-bit service UUIDs 

230 if uuid_128bit: 

231 ad_type = ADType.COMPLETE_128BIT_SERVICE_UUIDS if complete else ADType.INCOMPLETE_128BIT_SERVICE_UUIDS 

232 builder = builder._add_structure(ad_type, b"".join(uuid_128bit)) 

233 

234 return builder 

235 

236 def with_manufacturer_data( 

237 self, 

238 company_id: int | CompanyIdentifier, 

239 payload: bytes, 

240 ) -> AdvertisementBuilder: 

241 """Add manufacturer-specific data. 

242 

243 Args: 

244 company_id: Bluetooth SIG company identifier (e.g., 0x004C for Apple). 

245 payload: Manufacturer-specific payload bytes. 

246 

247 Returns: 

248 New builder with manufacturer data added. 

249 

250 """ 

251 cid = company_id.id if isinstance(company_id, CompanyIdentifier) else company_id 

252 

253 # Company ID is little-endian uint16 

254 data = struct.pack("<H", cid) + payload 

255 return self._add_structure(ADType.MANUFACTURER_SPECIFIC_DATA, data) 

256 

257 def with_manufacturer_data_struct( 

258 self, 

259 mfr_data: ManufacturerData, 

260 ) -> AdvertisementBuilder: 

261 """Add manufacturer-specific data from ManufacturerData struct. 

262 

263 Args: 

264 mfr_data: ManufacturerData instance. 

265 

266 Returns: 

267 New builder with manufacturer data added. 

268 

269 """ 

270 return self.with_manufacturer_data(mfr_data.company.id, mfr_data.payload) 

271 

272 def with_service_data( 

273 self, 

274 service_uuid: str | BluetoothUUID, 

275 data: bytes, 

276 ) -> AdvertisementBuilder: 

277 """Add service data. 

278 

279 Args: 

280 service_uuid: Service UUID. 

281 data: Service-specific data bytes. 

282 

283 Returns: 

284 New builder with service data added. 

285 

286 """ 

287 bt_uuid = BluetoothUUID(str(service_uuid)) 

288 full_form = bt_uuid.full_form 

289 

290 # Check if this uses the SIG base UUID (can be encoded as 16-bit) 

291 if full_form.endswith(BluetoothUUID.SIG_BASE_SUFFIX) and full_form.startswith("0000"): 

292 # 16-bit service data 

293 short_hex = full_form[4:8] 

294 short_id = int(short_hex, 16) 

295 uuid_bytes = struct.pack("<H", short_id) 

296 return self._add_structure(ADType.SERVICE_DATA_16BIT, uuid_bytes + data) 

297 

298 # 128-bit service data 

299 uuid_bytes = bt_uuid.to_bytes() 

300 return self._add_structure(ADType.SERVICE_DATA_128BIT, uuid_bytes + data) 

301 

302 def with_appearance(self, appearance: int) -> AdvertisementBuilder: 

303 """Add device appearance. 

304 

305 Args: 

306 appearance: 16-bit appearance value from Appearance registry. 

307 

308 Returns: 

309 New builder with appearance added. 

310 

311 """ 

312 data = struct.pack("<H", appearance) 

313 return self._add_structure(ADType.APPEARANCE, data) 

314 

315 def with_raw_structure(self, ad_type: int, data: bytes) -> AdvertisementBuilder: 

316 """Add a raw AD structure. 

317 

318 Use this for AD types not covered by other methods. 

319 

320 Args: 

321 ad_type: AD type constant. 

322 data: Raw data bytes. 

323 

324 Returns: 

325 New builder with structure added. 

326 

327 """ 

328 return self._add_structure(ad_type, data) 

329 

330 def current_size(self) -> int: 

331 """Get current encoded payload size. 

332 

333 Returns: 

334 Total size in bytes of all AD structures. 

335 

336 """ 

337 return sum(len(s) for s in self.structures) 

338 

339 def remaining_space(self) -> int: 

340 """Get remaining space in payload. 

341 

342 Returns: 

343 Bytes remaining before max_payload_size. 

344 

345 """ 

346 return self.max_payload_size - self.current_size() 

347 

348 def build(self) -> bytes: 

349 """Build the final advertising payload. 

350 

351 Returns: 

352 Concatenated AD structures as bytes. 

353 

354 Raises: 

355 ValueError: If payload exceeds max_payload_size. 

356 

357 """ 

358 payload = b"".join(s.to_bytes() for s in self.structures) 

359 

360 if len(payload) > self.max_payload_size: 

361 raise ValueError(f"Advertising payload too large: {len(payload)} bytes, max {self.max_payload_size}") 

362 

363 return payload 

364 

365 

366def encode_manufacturer_data(company_id: int, payload: bytes) -> bytes: 

367 """Encode manufacturer-specific data to bytes. 

368 

369 Args: 

370 company_id: Bluetooth SIG company identifier. 

371 payload: Manufacturer-specific payload. 

372 

373 Returns: 

374 Encoded bytes (company ID little-endian + payload). 

375 

376 """ 

377 return struct.pack("<H", company_id) + payload 

378 

379 

380def encode_service_uuids_16bit(uuids: Sequence[str | BluetoothUUID]) -> bytes: 

381 """Encode 16-bit service UUIDs. 

382 

383 Args: 

384 uuids: Service UUIDs (must be SIG UUIDs using base UUID). 

385 

386 Returns: 

387 Concatenated little-endian 16-bit UUIDs. 

388 

389 Raises: 

390 ValueError: If any UUID cannot be encoded as 16-bit. 

391 

392 """ 

393 result = bytearray() 

394 for uuid in uuids: 

395 bt_uuid = BluetoothUUID(str(uuid)) 

396 full_form = bt_uuid.full_form 

397 

398 # Check if this uses the SIG base UUID 

399 if not (full_form.endswith(BluetoothUUID.SIG_BASE_SUFFIX) and full_form.startswith("0000")): 

400 raise ValueError(f"UUID {uuid} cannot be encoded as 16-bit SIG UUID") 

401 

402 short_hex = full_form[4:8] 

403 short_id = int(short_hex, 16) 

404 result.extend(struct.pack("<H", short_id)) 

405 return bytes(result) 

406 

407 

408def encode_service_uuids_128bit(uuids: Sequence[str | BluetoothUUID]) -> bytes: 

409 """Encode 128-bit service UUIDs. 

410 

411 Args: 

412 uuids: Service UUIDs. 

413 

414 Returns: 

415 Concatenated 128-bit UUID bytes. 

416 

417 """ 

418 result = bytearray() 

419 for uuid in uuids: 

420 bt_uuid = BluetoothUUID(str(uuid)) 

421 result.extend(bt_uuid.to_bytes()) 

422 return bytes(result) 

423 

424 

425__all__ = [ 

426 "ADStructure", 

427 "AdvertisementBuilder", 

428 "encode_manufacturer_data", 

429 "encode_service_uuids_16bit", 

430 "encode_service_uuids_128bit", 

431]