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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Advertisement data builder for BLE peripheral encoding.
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.
7Reference: Bluetooth Core Specification Supplement, Part A (Advertising Data).
8"""
10from __future__ import annotations
12import struct
13from collections.abc import Sequence
15import msgspec
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
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
26class ADStructure(msgspec.Struct, frozen=True, kw_only=True):
27 """Single AD structure (Length-Type-Data format).
29 Each AD structure consists of:
30 - 1 byte: Length (length of type + data)
31 - 1 byte: AD Type
32 - N bytes: Data
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.
38 """
40 ad_type: int
41 data: bytes
43 def to_bytes(self) -> bytes:
44 """Encode to wire format (length-type-data).
46 Returns:
47 Encoded bytes ready for advertising payload.
49 Raises:
50 ValueError: If data exceeds maximum AD structure size (254 bytes).
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
58 def __len__(self) -> int:
59 """Total encoded length including length byte."""
60 return 2 + len(self.data)
63class AdvertisementBuilder(msgspec.Struct, frozen=True, kw_only=True):
64 r"""Builder for constructing BLE advertising payloads.
66 Provides a fluent interface for building advertising data with proper
67 encoding according to Bluetooth Core Specification.
69 The builder accumulates AD structures and can produce a final encoded
70 payload suitable for use with BLE peripheral APIs.
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()
87 Attributes:
88 structures: List of AD structures accumulated so far.
89 max_payload_size: Maximum payload size (31 for legacy, 254 for extended).
91 """
93 # Standard advertising payload limits
94 LEGACY_MAX_SIZE: int = 31
95 EXTENDED_MAX_SIZE: int = 254
97 structures: list[ADStructure] = msgspec.field(default_factory=list)
98 max_payload_size: int = LEGACY_MAX_SIZE
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 )
108 def with_extended_advertising(self) -> AdvertisementBuilder:
109 """Enable extended advertising mode (up to 254 bytes).
111 Returns:
112 New builder with extended payload size limit.
114 """
115 return AdvertisementBuilder(
116 structures=list(self.structures),
117 max_payload_size=self.EXTENDED_MAX_SIZE,
118 )
120 def with_flags(self, flags: BLEAdvertisingFlags | int) -> AdvertisementBuilder:
121 """Add advertising flags.
123 Args:
124 flags: BLEAdvertisingFlags enum or raw int value.
126 Returns:
127 New builder with flags added.
129 """
130 flag_value = int(flags)
131 return self._add_structure(ADType.FLAGS, bytes([flag_value]))
133 def with_complete_local_name(self, name: str) -> AdvertisementBuilder:
134 """Add complete local name.
136 Args:
137 name: Device name (UTF-8 encoded).
139 Returns:
140 New builder with name added.
142 """
143 return self._add_structure(
144 ADType.COMPLETE_LOCAL_NAME,
145 name.encode("utf-8"),
146 )
148 def with_shortened_local_name(self, name: str) -> AdvertisementBuilder:
149 """Add shortened local name.
151 Args:
152 name: Shortened device name (UTF-8 encoded).
154 Returns:
155 New builder with shortened name added.
157 """
158 return self._add_structure(
159 ADType.SHORTENED_LOCAL_NAME,
160 name.encode("utf-8"),
161 )
163 def with_tx_power(self, power_dbm: int) -> AdvertisementBuilder:
164 """Add TX power level.
166 Args:
167 power_dbm: Transmission power in dBm (-127 to +127).
169 Returns:
170 New builder with TX power added.
172 """
173 # TX power is signed int8
174 data = struct.pack("b", power_dbm)
175 return self._add_structure(ADType.TX_POWER_LEVEL, data)
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.
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
189 Args:
190 uuids: Service UUIDs to advertise.
191 complete: If True, use "Complete" list types; else "Incomplete".
193 Returns:
194 New builder with service UUIDs added (unchanged if uuids is empty).
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 )
204 builder = self
206 # Group by size for efficient encoding
207 uuid_16bit: list[bytes] = []
208 uuid_128bit: list[bytes] = []
210 for uuid in uuids:
211 bt_uuid = BluetoothUUID(str(uuid))
212 full_form = bt_uuid.full_form
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())
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))
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))
234 return builder
236 def with_manufacturer_data(
237 self,
238 company_id: int | CompanyIdentifier,
239 payload: bytes,
240 ) -> AdvertisementBuilder:
241 """Add manufacturer-specific data.
243 Args:
244 company_id: Bluetooth SIG company identifier (e.g., 0x004C for Apple).
245 payload: Manufacturer-specific payload bytes.
247 Returns:
248 New builder with manufacturer data added.
250 """
251 cid = company_id.id if isinstance(company_id, CompanyIdentifier) else company_id
253 # Company ID is little-endian uint16
254 data = struct.pack("<H", cid) + payload
255 return self._add_structure(ADType.MANUFACTURER_SPECIFIC_DATA, data)
257 def with_manufacturer_data_struct(
258 self,
259 mfr_data: ManufacturerData,
260 ) -> AdvertisementBuilder:
261 """Add manufacturer-specific data from ManufacturerData struct.
263 Args:
264 mfr_data: ManufacturerData instance.
266 Returns:
267 New builder with manufacturer data added.
269 """
270 return self.with_manufacturer_data(mfr_data.company.id, mfr_data.payload)
272 def with_service_data(
273 self,
274 service_uuid: str | BluetoothUUID,
275 data: bytes,
276 ) -> AdvertisementBuilder:
277 """Add service data.
279 Args:
280 service_uuid: Service UUID.
281 data: Service-specific data bytes.
283 Returns:
284 New builder with service data added.
286 """
287 bt_uuid = BluetoothUUID(str(service_uuid))
288 full_form = bt_uuid.full_form
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)
298 # 128-bit service data
299 uuid_bytes = bt_uuid.to_bytes()
300 return self._add_structure(ADType.SERVICE_DATA_128BIT, uuid_bytes + data)
302 def with_appearance(self, appearance: int) -> AdvertisementBuilder:
303 """Add device appearance.
305 Args:
306 appearance: 16-bit appearance value from Appearance registry.
308 Returns:
309 New builder with appearance added.
311 """
312 data = struct.pack("<H", appearance)
313 return self._add_structure(ADType.APPEARANCE, data)
315 def with_raw_structure(self, ad_type: int, data: bytes) -> AdvertisementBuilder:
316 """Add a raw AD structure.
318 Use this for AD types not covered by other methods.
320 Args:
321 ad_type: AD type constant.
322 data: Raw data bytes.
324 Returns:
325 New builder with structure added.
327 """
328 return self._add_structure(ad_type, data)
330 def current_size(self) -> int:
331 """Get current encoded payload size.
333 Returns:
334 Total size in bytes of all AD structures.
336 """
337 return sum(len(s) for s in self.structures)
339 def remaining_space(self) -> int:
340 """Get remaining space in payload.
342 Returns:
343 Bytes remaining before max_payload_size.
345 """
346 return self.max_payload_size - self.current_size()
348 def build(self) -> bytes:
349 """Build the final advertising payload.
351 Returns:
352 Concatenated AD structures as bytes.
354 Raises:
355 ValueError: If payload exceeds max_payload_size.
357 """
358 payload = b"".join(s.to_bytes() for s in self.structures)
360 if len(payload) > self.max_payload_size:
361 raise ValueError(f"Advertising payload too large: {len(payload)} bytes, max {self.max_payload_size}")
363 return payload
366def encode_manufacturer_data(company_id: int, payload: bytes) -> bytes:
367 """Encode manufacturer-specific data to bytes.
369 Args:
370 company_id: Bluetooth SIG company identifier.
371 payload: Manufacturer-specific payload.
373 Returns:
374 Encoded bytes (company ID little-endian + payload).
376 """
377 return struct.pack("<H", company_id) + payload
380def encode_service_uuids_16bit(uuids: Sequence[str | BluetoothUUID]) -> bytes:
381 """Encode 16-bit service UUIDs.
383 Args:
384 uuids: Service UUIDs (must be SIG UUIDs using base UUID).
386 Returns:
387 Concatenated little-endian 16-bit UUIDs.
389 Raises:
390 ValueError: If any UUID cannot be encoded as 16-bit.
392 """
393 result = bytearray()
394 for uuid in uuids:
395 bt_uuid = BluetoothUUID(str(uuid))
396 full_form = bt_uuid.full_form
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")
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)
408def encode_service_uuids_128bit(uuids: Sequence[str | BluetoothUUID]) -> bytes:
409 """Encode 128-bit service UUIDs.
411 Args:
412 uuids: Service UUIDs.
414 Returns:
415 Concatenated 128-bit UUID bytes.
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)
425__all__ = [
426 "ADStructure",
427 "AdvertisementBuilder",
428 "encode_manufacturer_data",
429 "encode_service_uuids_16bit",
430 "encode_service_uuids_128bit",
431]