Coverage for src/bluetooth_sig/device/peripheral.py: 76%
112 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 01:26 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 01:26 +0000
1"""Peripheral manager protocol for BLE GATT server adapters.
3Defines an async abstract base class that peripheral adapter implementations
4(bless, bluez_peripheral, etc.) must inherit from to create BLE GATT servers
5that broadcast services and characteristics.
7Adapters must provide async implementations of all abstract methods below.
8"""
10from __future__ import annotations
12from abc import ABC, abstractmethod
13from collections.abc import Callable
14from typing import ClassVar
16from typing_extensions import Self
18from bluetooth_sig.types.company import CompanyIdentifier, ManufacturerData
19from bluetooth_sig.types.peripheral_types import CharacteristicDefinition, ServiceDefinition
20from bluetooth_sig.types.uuid import BluetoothUUID
23class PeripheralManagerProtocol(ABC):
24 """Abstract base class for BLE peripheral/GATT server implementations.
26 .. deprecated:: 0.5.0
27 Scheduled for removal; see ``docs/source/explanation/limitations.md``.
29 This protocol defines the interface for creating BLE peripherals that
30 broadcast services and characteristics. Implementations wrap backend
31 libraries like bless, bluez_peripheral, etc.
33 Uses a fluent builder pattern for advertisement configuration - call
34 configuration methods before start() to customise advertising.
36 The workflow is:
37 1. Create peripheral manager with a device name
38 2. Configure advertising (optional): with_manufacturer_data(), with_tx_power(), etc.
39 3. Add services and characteristics (using CharacteristicDefinition)
40 4. Start advertising
41 5. Update characteristic values as needed
42 6. Stop when done
44 Example::
45 >>> from bluetooth_sig.gatt.characteristics import BatteryLevelCharacteristic
46 >>> from bluetooth_sig.gatt.services import BatteryService
47 >>> from bluetooth_sig.types.company import ManufacturerData
48 >>>
49 >>> # Create peripheral with fluent configuration
50 >>> peripheral = SomePeripheralManager("My Sensor")
51 >>> peripheral.with_tx_power(-10).with_connectable(True)
52 >>>
53 >>> # Define a service with battery level
54 >>> char = BatteryLevelCharacteristic()
55 >>> char_def = CharacteristicDefinition.from_characteristic(char, 85)
56 >>>
57 >>> service = ServiceDefinition(
58 ... uuid=BatteryService.get_class_uuid(),
59 ... characteristics=[char_def],
60 ... )
61 >>>
62 >>> await peripheral.add_service(service)
63 >>> await peripheral.start()
64 >>>
65 >>> # Later, update the battery level
66 >>> await peripheral.update_characteristic("2A19", char.build_value(75))
68 """
70 # Class-level flag indicating backend capabilities
71 supports_advertising: ClassVar[bool] = True
73 def __init__(self, name: str) -> None:
74 """Initialize the peripheral manager.
76 Args:
77 name: The advertised device name visible to scanners
79 """
80 self._name = name
82 # Service and characteristic tracking
83 self._services: list[ServiceDefinition] = []
84 self._char_definitions: dict[str, CharacteristicDefinition] = {}
86 # Advertisement configuration (set via fluent methods)
87 self._manufacturer_data: ManufacturerData | None = None
88 self._service_data: dict[BluetoothUUID, bytes] = {}
89 self._tx_power: int | None = None
90 self._is_connectable = True
91 self._is_discoverable = True
93 # Callbacks for read/write handling
94 self._read_callbacks: dict[str, Callable[[], bytearray]] = {}
95 self._write_callbacks: dict[str, Callable[[bytearray], None]] = {}
97 @property
98 def name(self) -> str:
99 """Get the advertised device name.
101 Returns:
102 The device name as it appears to BLE scanners
104 """
105 return self._name
107 @property
108 def services(self) -> list[ServiceDefinition]:
109 """Get the list of registered services.
111 Returns:
112 List of ServiceDefinition objects added to this peripheral.
114 """
115 return self._services
117 @property
118 def manufacturer_data(self) -> ManufacturerData | None:
119 """Get the configured manufacturer data.
121 Returns:
122 ManufacturerData if configured, None otherwise.
124 """
125 return self._manufacturer_data
127 @property
128 def service_data(self) -> dict[BluetoothUUID, bytes]:
129 """Get the configured service data.
131 Returns:
132 Dictionary mapping service UUIDs to data bytes.
134 """
135 return self._service_data
137 @property
138 def tx_power(self) -> int | None:
139 """Get the configured TX power level.
141 Returns:
142 TX power in dBm if configured, None otherwise.
144 """
145 return self._tx_power
147 @property
148 def is_connectable_config(self) -> bool:
149 """Get the connectable configuration.
151 Returns:
152 True if peripheral is configured to accept connections.
154 """
155 return self._is_connectable
157 @property
158 def is_discoverable_config(self) -> bool:
159 """Get the discoverable configuration.
161 Returns:
162 True if peripheral is configured to be discoverable.
164 """
165 return self._is_discoverable
167 def with_manufacturer_data(self, manufacturer_data: ManufacturerData) -> Self:
168 r"""Set manufacturer-specific advertising data.
170 Args:
171 manufacturer_data: ManufacturerData instance from the types module.
173 Returns:
174 Self for method chaining.
176 Raises:
177 RuntimeError: If called after start().
179 Example::
180 >>> from bluetooth_sig.types.company import ManufacturerData
181 >>> mfr = ManufacturerData.from_id_and_payload(0x004C, b"\x02\x15...")
182 >>> peripheral.with_manufacturer_data(mfr)
184 """
185 if self.is_advertising:
186 raise RuntimeError("Cannot configure after peripheral has started")
187 self._manufacturer_data = manufacturer_data
188 return self
190 def with_manufacturer_id(
191 self,
192 company_id: int | CompanyIdentifier,
193 payload: bytes,
194 ) -> Self:
195 r"""Set manufacturer data from company ID and payload.
197 Args:
198 company_id: Bluetooth SIG company identifier (e.g., 0x004C for Apple)
199 or CompanyIdentifier instance.
200 payload: Manufacturer-specific payload bytes.
202 Returns:
203 Self for method chaining.
205 Raises:
206 RuntimeError: If called after start().
208 Example::
209 >>> peripheral.with_manufacturer_id(0x004C, b"\x02\x15...")
211 """
212 if self.is_advertising:
213 raise RuntimeError("Cannot configure after peripheral has started")
214 cid = company_id if isinstance(company_id, int) else company_id.id
215 self._manufacturer_data = ManufacturerData.from_id_and_payload(cid, payload)
216 return self
218 def with_service_data(
219 self,
220 service_uuid: BluetoothUUID,
221 data: bytes,
222 ) -> Self:
223 r"""Add service data to advertisement.
225 Args:
226 service_uuid: BluetoothUUID of the service.
227 data: Service-specific data bytes.
229 Returns:
230 Self for method chaining.
232 Raises:
233 RuntimeError: If called after start().
235 Example::
236 >>> from bluetooth_sig.gatt.services import BatteryService
237 >>> peripheral.with_service_data(
238 ... BatteryService.get_class_uuid(),
239 ... b"\x50", # 80% battery
240 ... )
242 """
243 if self.is_advertising:
244 raise RuntimeError("Cannot configure after peripheral has started")
245 self._service_data[service_uuid] = data
246 return self
248 def with_tx_power(self, power_dbm: int) -> Self:
249 """Set TX power level for advertising.
251 Args:
252 power_dbm: Transmission power in dBm (-127 to +127).
254 Returns:
255 Self for method chaining.
257 Raises:
258 RuntimeError: If called after start().
260 """
261 if self.is_advertising:
262 raise RuntimeError("Cannot configure after peripheral has started")
263 self._tx_power = power_dbm
264 return self
266 def with_connectable(self, connectable: bool) -> Self:
267 """Set whether the peripheral accepts connections.
269 Args:
270 connectable: True to accept connections (default), False for broadcast only.
272 Returns:
273 Self for method chaining.
275 Raises:
276 RuntimeError: If called after start().
278 """
279 if self.is_advertising:
280 raise RuntimeError("Cannot configure after peripheral has started")
281 self._is_connectable = connectable
282 return self
284 def with_discoverable(self, discoverable: bool) -> Self:
285 """Set whether the peripheral is discoverable.
287 Args:
288 discoverable: True to be discoverable (default), False otherwise.
290 Returns:
291 Self for method chaining.
293 Raises:
294 RuntimeError: If called after start().
296 """
297 if self.is_advertising:
298 raise RuntimeError("Cannot configure after peripheral has started")
299 self._is_discoverable = discoverable
300 return self
302 # -------------------------------------------------------------------------
303 # Service Management (Generic Implementation)
304 # -------------------------------------------------------------------------
306 async def add_service(self, service: ServiceDefinition) -> None:
307 """Add a GATT service to the peripheral.
309 Services must be added before calling start(). Each service contains
310 one or more characteristics that clients can interact with.
312 Args:
313 service: The service definition to add
315 Raises:
316 RuntimeError: If called after start()
318 """
319 if self.is_advertising:
320 raise RuntimeError("Cannot add services after peripheral has started")
322 self._services.append(service)
324 # Track characteristic definitions for later lookup
325 for char_def in service.characteristics:
326 uuid_upper = str(char_def.uuid).upper()
327 self._char_definitions[uuid_upper] = char_def
329 # Register any callbacks from the definition
330 if char_def.on_read:
331 self._read_callbacks[uuid_upper] = char_def.on_read
332 if char_def.on_write:
333 self._write_callbacks[uuid_upper] = char_def.on_write
335 def get_characteristic_definition(
336 self,
337 char_uuid: str | BluetoothUUID,
338 ) -> CharacteristicDefinition | None:
339 """Get the characteristic definition by UUID.
341 Args:
342 char_uuid: UUID of the characteristic.
344 Returns:
345 CharacteristicDefinition if found, None otherwise.
347 """
348 uuid_upper = str(char_uuid).upper()
349 return self._char_definitions.get(uuid_upper)
351 def set_read_callback(
352 self,
353 char_uuid: str | BluetoothUUID,
354 callback: Callable[[], bytearray],
355 ) -> None:
356 """Set a callback for dynamic read value generation.
358 When a client reads the characteristic, this callback will be invoked
359 to generate the current value instead of returning the stored value.
361 Args:
362 char_uuid: UUID of the characteristic
363 callback: Function that returns the encoded value to serve
365 Raises:
366 KeyError: If characteristic UUID not found
368 """
369 uuid_str = str(char_uuid).upper()
370 if uuid_str not in self._char_definitions:
371 raise KeyError(f"Characteristic {uuid_str} not found")
372 self._read_callbacks[uuid_str] = callback
374 def set_write_callback(
375 self,
376 char_uuid: str | BluetoothUUID,
377 callback: Callable[[bytearray], None],
378 ) -> None:
379 """Set a callback for handling client writes.
381 When a client writes to the characteristic, this callback will be
382 invoked with the written data.
384 Args:
385 char_uuid: UUID of the characteristic
386 callback: Function called with the written data
388 Raises:
389 KeyError: If characteristic UUID not found
391 """
392 uuid_str = str(char_uuid).upper()
393 if uuid_str not in self._char_definitions:
394 raise KeyError(f"Characteristic {uuid_str} not found")
395 self._write_callbacks[uuid_str] = callback
397 # -------------------------------------------------------------------------
398 # Abstract Methods (Backend-Specific Implementation Required)
399 # -------------------------------------------------------------------------
401 @abstractmethod
402 async def start(self) -> None:
403 """Start advertising and accepting connections.
405 Backend implementations must:
406 1. Create the platform-specific GATT server
407 2. Register all services and characteristics from self._services
408 3. Configure advertisement data from self._manufacturer_data, etc.
409 4. Begin advertising
411 Raises:
412 RuntimeError: If no services have been added
414 """
416 @abstractmethod
417 async def stop(self) -> None:
418 """Stop advertising and disconnect all clients."""
420 @property
421 @abstractmethod
422 def is_advertising(self) -> bool:
423 """Check if the peripheral is currently advertising.
425 Returns:
426 True if advertising, False otherwise
428 """
430 @abstractmethod
431 async def update_characteristic(
432 self,
433 char_uuid: str | BluetoothUUID,
434 value: bytearray,
435 *,
436 notify: bool = True,
437 ) -> None:
438 """Update a characteristic's value.
440 This sets the new value that will be returned when clients read the
441 characteristic. If notify=True and the characteristic supports
442 notifications, subscribed clients will be notified of the change.
444 Args:
445 char_uuid: UUID of the characteristic to update
446 value: New encoded value (use characteristic.build_value() to encode)
447 notify: If True, notify subscribed clients of the change
449 Raises:
450 KeyError: If characteristic UUID not found
451 RuntimeError: If peripheral not started
453 """
455 @abstractmethod
456 async def get_characteristic_value(self, char_uuid: str | BluetoothUUID) -> bytearray:
457 """Get the current value of a characteristic.
459 Args:
460 char_uuid: UUID of the characteristic
462 Returns:
463 The current encoded value
465 Raises:
466 KeyError: If characteristic UUID not found
468 """
470 @property
471 def connected_clients(self) -> int:
472 """Get the number of currently connected clients.
474 Returns:
475 Number of connected BLE centrals
477 Raises:
478 NotImplementedError: If backend doesn't track connections
480 """
481 raise NotImplementedError(f"{self.__class__.__name__} does not track connected clients")
484__all__ = [
485 "CharacteristicDefinition",
486 "PeripheralManagerProtocol",
487 "ServiceDefinition",
488]